Add docker-ip-addr-manager initial app
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12.9-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
|
||||
EXPOSE 31810
|
||||
|
||||
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${APP_PORT:-31810}"]
|
||||
@@ -0,0 +1 @@
|
||||
"""docker-ip-addr-manager backend package."""
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
state_file: str
|
||||
docker_api_url: str
|
||||
docker_timeout_seconds: float
|
||||
app_port: int
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings(
|
||||
state_file=os.getenv("STATE_FILE", "/data/entries.json"),
|
||||
docker_api_url=os.getenv("DOCKER_API_URL", "unix:///var/run/docker.sock"),
|
||||
docker_timeout_seconds=float(os.getenv("DOCKER_TIMEOUT_SECONDS", "3")),
|
||||
app_port=int(os.getenv("APP_PORT", "31810")),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
|
||||
class DockerApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path: str, timeout: float):
|
||||
super().__init__(host="localhost", timeout=timeout)
|
||||
self._socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
sock.connect(self._socket_path)
|
||||
self.sock = sock
|
||||
|
||||
|
||||
class DockerApiClient:
|
||||
def __init__(self, base_url: str, timeout_seconds: float = 3.0):
|
||||
parsed = urlparse(base_url)
|
||||
self._timeout = timeout_seconds
|
||||
|
||||
if parsed.scheme == "unix":
|
||||
self._mode = "unix"
|
||||
self._socket_path = parsed.path or "/var/run/docker.sock"
|
||||
self._base_path = ""
|
||||
elif parsed.scheme in {"http", "https"}:
|
||||
if not parsed.netloc:
|
||||
raise ValueError(f"Invalid Docker URL (missing host): {base_url}")
|
||||
self._mode = parsed.scheme
|
||||
self._host = parsed.hostname or "localhost"
|
||||
self._port = parsed.port
|
||||
self._base_path = parsed.path.rstrip("/")
|
||||
else:
|
||||
raise ValueError(f"Unsupported Docker URL scheme: {parsed.scheme}")
|
||||
|
||||
def list_containers(self, include_stopped: bool = True) -> list[dict]:
|
||||
query = urlencode({"all": "1" if include_stopped else "0"})
|
||||
return self._get_json(f"/containers/json?{query}")
|
||||
|
||||
def inspect_container(self, container_id: str) -> dict:
|
||||
return self._get_json(f"/containers/{container_id}/json")
|
||||
|
||||
def _get_json(self, path: str):
|
||||
body = self._get(path)
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise DockerApiError(f"Docker API returned invalid JSON for {path}: {exc}") from exc
|
||||
|
||||
def _get(self, path: str) -> str:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
if self._mode == "unix":
|
||||
conn = _UnixHTTPConnection(self._socket_path, timeout=self._timeout)
|
||||
request_path = f"{self._base_path}{path}"
|
||||
else:
|
||||
if self._mode == "https":
|
||||
conn = http.client.HTTPSConnection(self._host, self._port, timeout=self._timeout)
|
||||
else:
|
||||
conn = http.client.HTTPConnection(self._host, self._port, timeout=self._timeout)
|
||||
request_path = f"{self._base_path}{path}"
|
||||
|
||||
try:
|
||||
conn.request("GET", request_path)
|
||||
response = conn.getresponse()
|
||||
payload = response.read().decode("utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
raise DockerApiError(f"Docker API request failed for {path}: {exc}") from exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if response.status < 200 or response.status >= 300:
|
||||
raise DockerApiError(
|
||||
f"Docker API error for {path}: HTTP {response.status} {response.reason}; body={payload[:400]}"
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class DockerUsageResolver:
|
||||
def __init__(self, docker_client: DockerApiClient):
|
||||
self._docker = docker_client
|
||||
|
||||
def resolve_ip_usage(self, ips: set[str]) -> dict[str, set[str]]:
|
||||
usage: dict[str, set[str]] = {ip: set() for ip in ips}
|
||||
if not ips:
|
||||
return usage
|
||||
|
||||
containers = self._docker.list_containers(include_stopped=True)
|
||||
for container in containers:
|
||||
container_id = container.get("Id")
|
||||
if not container_id:
|
||||
continue
|
||||
inspect_payload = self._docker.inspect_container(container_id)
|
||||
container_name = _display_name(container, inspect_payload)
|
||||
ports = ((inspect_payload.get("NetworkSettings") or {}).get("Ports") or {})
|
||||
if not isinstance(ports, dict):
|
||||
continue
|
||||
|
||||
for bindings in ports.values():
|
||||
if not bindings:
|
||||
continue
|
||||
for binding in bindings:
|
||||
if not isinstance(binding, dict):
|
||||
continue
|
||||
host_ip = binding.get("HostIp")
|
||||
if host_ip in usage:
|
||||
usage[host_ip].add(container_name)
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def _display_name(container_summary: dict, container_inspect: dict) -> str:
|
||||
inspect_name = container_inspect.get("Name")
|
||||
if isinstance(inspect_name, str) and inspect_name.strip("/"):
|
||||
return inspect_name.strip("/")
|
||||
|
||||
names = container_summary.get("Names")
|
||||
if isinstance(names, list) and names:
|
||||
first_name = str(names[0]).strip("/")
|
||||
if first_name:
|
||||
return first_name
|
||||
|
||||
container_id = container_summary.get("Id", "")
|
||||
return str(container_id)[:12] or "unknown"
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def list_host_interfaces() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["ip", "-o", "link", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to list interfaces: {result.stderr.strip()}")
|
||||
|
||||
interfaces: list[str] = []
|
||||
seen = set()
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split(":", maxsplit=2)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name = parts[1].strip().split("@", maxsplit=1)[0]
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
interfaces.append(name)
|
||||
|
||||
return interfaces
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
def _default_runner(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
|
||||
|
||||
class IpAddressManager:
|
||||
def __init__(self, runner: Runner | None = None):
|
||||
self._runner = runner or _default_runner
|
||||
|
||||
def is_present(self, ip: str, cidr: int, device: str) -> bool:
|
||||
target = f"{ip}/{cidr}"
|
||||
result = self._runner(["ip", "-4", "-o", "addr", "show", "dev", device])
|
||||
if result.returncode != 0:
|
||||
raise CommandError(f"Failed to query addresses on {device}: {result.stderr.strip()}")
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
tokens = line.split()
|
||||
if target in tokens:
|
||||
return True
|
||||
return False
|
||||
|
||||
def ensure_present(self, ip: str, cidr: int, device: str) -> None:
|
||||
if self.is_present(ip, cidr, device):
|
||||
return
|
||||
|
||||
result = self._runner(["ip", "addr", "add", f"{ip}/{cidr}", "dev", device])
|
||||
if result.returncode != 0 and "File exists" not in result.stderr:
|
||||
raise CommandError(f"Failed to add address {ip}/{cidr} on {device}: {result.stderr.strip()}")
|
||||
|
||||
def ensure_absent(self, ip: str, cidr: int, device: str) -> None:
|
||||
if not self.is_present(ip, cidr, device):
|
||||
return
|
||||
|
||||
result = self._runner(["ip", "addr", "del", f"{ip}/{cidr}", "dev", device])
|
||||
if result.returncode != 0 and "Cannot assign requested address" not in result.stderr:
|
||||
raise CommandError(f"Failed to remove address {ip}/{cidr} on {device}: {result.stderr.strip()}")
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import get_settings
|
||||
from app.docker_api import DockerApiClient, DockerApiError, DockerUsageResolver
|
||||
from app.ip_commands import CommandError, IpAddressManager
|
||||
from app.service import (
|
||||
ConflictError,
|
||||
DependencyError,
|
||||
EntryService,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.storage import EntryStorage
|
||||
|
||||
|
||||
def build_service() -> EntryService:
|
||||
settings = get_settings()
|
||||
storage = EntryStorage(settings.state_file)
|
||||
docker_client = DockerApiClient(settings.docker_api_url, timeout_seconds=settings.docker_timeout_seconds)
|
||||
usage_resolver = DockerUsageResolver(docker_client)
|
||||
ip_manager = IpAddressManager()
|
||||
return EntryService(storage=storage, usage_resolver=usage_resolver, ip_manager=ip_manager)
|
||||
|
||||
|
||||
service = build_service()
|
||||
app = FastAPI(title="Docker IP Addr Manager", version="0.1.0")
|
||||
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_reconcile() -> None:
|
||||
errors = service.reconcile_enabled_entries()
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"[startup-reconcile] {error}")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/interfaces")
|
||||
def list_interfaces() -> dict:
|
||||
try:
|
||||
return {"items": service.list_interfaces()}
|
||||
except DependencyError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/api/entries")
|
||||
def list_entries() -> dict:
|
||||
response = service.list_entries()
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/refresh")
|
||||
def refresh_entries() -> dict:
|
||||
response = service.list_entries()
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/entries")
|
||||
def create_entry(payload: dict) -> dict:
|
||||
try:
|
||||
entry = service.create_entry(payload)
|
||||
return entry.to_dict()
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.put("/api/entries/{entry_id}")
|
||||
def update_entry(entry_id: str, payload: dict) -> dict:
|
||||
try:
|
||||
entry = service.update_entry(entry_id, payload)
|
||||
return entry.to_dict()
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/entries/{entry_id}/enable")
|
||||
def enable_entry(entry_id: str) -> dict:
|
||||
try:
|
||||
entry = service.set_enabled(entry_id, enabled=True)
|
||||
return entry.to_dict()
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (ConflictError, CommandError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except DependencyError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/entries/{entry_id}/disable")
|
||||
def disable_entry(entry_id: str) -> dict:
|
||||
try:
|
||||
entry = service.set_enabled(entry_id, enabled=False)
|
||||
return entry.to_dict()
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (ConflictError, CommandError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except DependencyError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.delete("/api/entries/{entry_id}")
|
||||
def delete_entry(entry_id: str) -> dict:
|
||||
try:
|
||||
service.delete_entry(entry_id)
|
||||
return {"deleted": True}
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except DependencyError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.exception_handler(DockerApiError)
|
||||
async def docker_error_handler(_, exc: DockerApiError):
|
||||
return JSONResponse(status_code=503, content={"detail": str(exc)})
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class IpEntry:
|
||||
id: str
|
||||
name: str
|
||||
ip: str
|
||||
cidr: int
|
||||
device: str
|
||||
enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "IpEntry":
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
name=str(data["name"]),
|
||||
ip=str(data["ip"]),
|
||||
cidr=int(data["cidr"]),
|
||||
device=str(data["device"]),
|
||||
enabled=bool(data.get("enabled", False)),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"ip": self.ip,
|
||||
"cidr": self.cidr,
|
||||
"device": self.device,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntryView:
|
||||
id: str
|
||||
name: str
|
||||
ip: str
|
||||
cidr: int
|
||||
device: str
|
||||
enabled: bool
|
||||
effective_enabled: bool
|
||||
used: bool
|
||||
containers: list[str]
|
||||
usage_known: bool
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"ip": self.ip,
|
||||
"cidr": self.cidr,
|
||||
"device": self.device,
|
||||
"enabled": self.enabled,
|
||||
"effective_enabled": self.effective_enabled,
|
||||
"used": self.used,
|
||||
"containers": self.containers,
|
||||
"usage_known": self.usage_known,
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import IPv4Address
|
||||
import threading
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from app.docker_api import DockerApiError, DockerUsageResolver
|
||||
from app.interfaces import list_host_interfaces
|
||||
from app.ip_commands import CommandError, IpAddressManager
|
||||
from app.models import EntryView, IpEntry
|
||||
from app.storage import EntryStorage
|
||||
|
||||
|
||||
class ValidationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DependencyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntryListResponse:
|
||||
items: list[EntryView]
|
||||
usage_known: bool
|
||||
usage_error: str | None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"items": [item.to_dict() for item in self.items],
|
||||
"usage_known": self.usage_known,
|
||||
"usage_error": self.usage_error,
|
||||
}
|
||||
|
||||
|
||||
class EntryService:
|
||||
def __init__(
|
||||
self,
|
||||
storage: EntryStorage,
|
||||
usage_resolver: DockerUsageResolver,
|
||||
ip_manager: IpAddressManager,
|
||||
interface_provider: Callable[[], list[str]] = list_host_interfaces,
|
||||
):
|
||||
self._storage = storage
|
||||
self._usage_resolver = usage_resolver
|
||||
self._ip_manager = ip_manager
|
||||
self._interface_provider = interface_provider
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def list_interfaces(self) -> list[str]:
|
||||
interfaces = self._interface_provider()
|
||||
if not interfaces:
|
||||
raise DependencyError("No network interfaces discovered on host")
|
||||
return interfaces
|
||||
|
||||
def list_entries(self) -> EntryListResponse:
|
||||
entries = self._storage.list_entries()
|
||||
usage_map, usage_known, usage_error = self._resolve_usage(entries)
|
||||
|
||||
views: list[EntryView] = []
|
||||
for entry in entries:
|
||||
containers = sorted(usage_map.get(entry.ip, set()))
|
||||
used = bool(containers) if usage_known else False
|
||||
effective_enabled = False
|
||||
try:
|
||||
effective_enabled = self._ip_manager.is_present(entry.ip, entry.cidr, entry.device)
|
||||
except CommandError:
|
||||
effective_enabled = False
|
||||
|
||||
views.append(
|
||||
EntryView(
|
||||
id=entry.id,
|
||||
name=entry.name,
|
||||
ip=entry.ip,
|
||||
cidr=entry.cidr,
|
||||
device=entry.device,
|
||||
enabled=entry.enabled,
|
||||
effective_enabled=effective_enabled,
|
||||
used=used,
|
||||
containers=containers,
|
||||
usage_known=usage_known,
|
||||
)
|
||||
)
|
||||
|
||||
return EntryListResponse(items=views, usage_known=usage_known, usage_error=usage_error)
|
||||
|
||||
def create_entry(self, payload: dict) -> IpEntry:
|
||||
parsed = _parse_payload(payload)
|
||||
with self._lock:
|
||||
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"])
|
||||
|
||||
created = IpEntry(
|
||||
id=uuid4().hex,
|
||||
name=parsed["name"],
|
||||
ip=parsed["ip"],
|
||||
cidr=parsed["cidr"],
|
||||
device=parsed["device"],
|
||||
enabled=False,
|
||||
)
|
||||
entries.append(created)
|
||||
self._storage.save_entries(entries)
|
||||
return created
|
||||
|
||||
def update_entry(self, entry_id: str, payload: dict) -> IpEntry:
|
||||
parsed = _parse_payload(payload)
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
index, current = _find_entry(entries, entry_id)
|
||||
if current.enabled:
|
||||
raise ConflictError("Disable entry before updating IP/cidr/device")
|
||||
|
||||
self._assert_device_exists(parsed["device"])
|
||||
self._assert_unique_binding(
|
||||
entries,
|
||||
ip=parsed["ip"],
|
||||
cidr=parsed["cidr"],
|
||||
device=parsed["device"],
|
||||
ignore_entry_id=entry_id,
|
||||
)
|
||||
updated = IpEntry(
|
||||
id=current.id,
|
||||
name=parsed["name"],
|
||||
ip=parsed["ip"],
|
||||
cidr=parsed["cidr"],
|
||||
device=parsed["device"],
|
||||
enabled=False,
|
||||
)
|
||||
entries[index] = updated
|
||||
self._storage.save_entries(entries)
|
||||
return updated
|
||||
|
||||
def set_enabled(self, entry_id: str, enabled: bool) -> IpEntry:
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
index, entry = _find_entry(entries, entry_id)
|
||||
|
||||
if enabled:
|
||||
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = True
|
||||
else:
|
||||
self._assert_not_used(entry)
|
||||
self._ip_manager.ensure_absent(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = False
|
||||
|
||||
entries[index] = entry
|
||||
self._storage.save_entries(entries)
|
||||
return entry
|
||||
|
||||
def delete_entry(self, entry_id: str) -> None:
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
_, entry = _find_entry(entries, entry_id)
|
||||
if entry.enabled:
|
||||
raise ConflictError("Disable entry before deleting")
|
||||
|
||||
self._assert_not_used(entry)
|
||||
remaining = [candidate for candidate in entries if candidate.id != entry_id]
|
||||
self._storage.save_entries(remaining)
|
||||
|
||||
def reconcile_enabled_entries(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
changed = False
|
||||
for entry in entries:
|
||||
if not entry.enabled:
|
||||
continue
|
||||
try:
|
||||
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
|
||||
changed = True
|
||||
except CommandError as exc:
|
||||
errors.append(f"{entry.name} ({entry.ip}/{entry.cidr} {entry.device}): {exc}")
|
||||
if changed:
|
||||
self._storage.save_entries(entries)
|
||||
return errors
|
||||
|
||||
def _assert_not_used(self, entry: IpEntry) -> None:
|
||||
try:
|
||||
usage = self._usage_resolver.resolve_ip_usage({entry.ip})
|
||||
except DockerApiError as exc:
|
||||
raise DependencyError(f"Docker usage check failed: {exc}") from exc
|
||||
|
||||
containers = sorted(usage.get(entry.ip, set()))
|
||||
if containers:
|
||||
raise ConflictError(
|
||||
"Entry is currently used by container port bindings: " + ", ".join(containers)
|
||||
)
|
||||
|
||||
def _resolve_usage(self, entries: list[IpEntry]) -> tuple[dict[str, set[str]], bool, str | None]:
|
||||
ips = {entry.ip for entry in entries}
|
||||
if not ips:
|
||||
return {}, True, None
|
||||
|
||||
try:
|
||||
usage = self._usage_resolver.resolve_ip_usage(ips)
|
||||
return usage, True, None
|
||||
except DockerApiError as exc:
|
||||
return {ip: set() for ip in ips}, False, str(exc)
|
||||
|
||||
def _assert_unique_binding(
|
||||
self,
|
||||
entries: list[IpEntry],
|
||||
ip: str,
|
||||
cidr: int,
|
||||
device: str,
|
||||
ignore_entry_id: str | None = None,
|
||||
) -> None:
|
||||
for entry in entries:
|
||||
if ignore_entry_id and entry.id == ignore_entry_id:
|
||||
continue
|
||||
if entry.ip == ip and entry.cidr == cidr and entry.device == device:
|
||||
raise ConflictError("Entry with same ip/cidr/device already exists")
|
||||
|
||||
def _assert_device_exists(self, device: str) -> None:
|
||||
interfaces = self.list_interfaces()
|
||||
if device not in interfaces:
|
||||
raise ValidationError(f"Unknown network device: {device}")
|
||||
|
||||
|
||||
def _find_entry(entries: list[IpEntry], entry_id: str) -> tuple[int, IpEntry]:
|
||||
for index, entry in enumerate(entries):
|
||||
if entry.id == entry_id:
|
||||
return index, entry
|
||||
raise NotFoundError(f"Entry not found: {entry_id}")
|
||||
|
||||
|
||||
def _parse_payload(payload: dict) -> dict:
|
||||
name = str(payload.get("name", "")).strip()
|
||||
ip = str(payload.get("ip", "")).strip()
|
||||
device = str(payload.get("device", "")).strip()
|
||||
|
||||
if not name:
|
||||
raise ValidationError("Field 'name' is required")
|
||||
if len(name) > 96:
|
||||
raise ValidationError("Field 'name' is too long (max 96)")
|
||||
|
||||
if not ip:
|
||||
raise ValidationError("Field 'ip' is required")
|
||||
try:
|
||||
ip_obj = IPv4Address(ip)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"Invalid IPv4 address: {ip}") from exc
|
||||
|
||||
if not device:
|
||||
raise ValidationError("Field 'device' is required")
|
||||
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:
|
||||
raise ValidationError("Field 'cidr' is required")
|
||||
try:
|
||||
cidr = int(raw_cidr)
|
||||
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")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"ip": str(ip_obj),
|
||||
"cidr": cidr,
|
||||
"device": device,
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
const { createApp } = Vue;
|
||||
|
||||
function mapEntry(entry) {
|
||||
return {
|
||||
...entry,
|
||||
draft: {
|
||||
name: entry.name,
|
||||
ip: entry.ip,
|
||||
cidr: entry.cidr,
|
||||
device: entry.device,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
interfaces: [],
|
||||
entries: [],
|
||||
usageKnown: true,
|
||||
usageError: "",
|
||||
loading: false,
|
||||
saving: false,
|
||||
busyById: {},
|
||||
errorMessage: "",
|
||||
form: {
|
||||
name: "",
|
||||
ip: "",
|
||||
cidr: 16,
|
||||
device: "",
|
||||
},
|
||||
sort: {
|
||||
key: "name",
|
||||
direction: "asc",
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sortedEntries() {
|
||||
const list = [...this.entries];
|
||||
const key = this.sort.key;
|
||||
const multiplier = this.sort.direction === "asc" ? 1 : -1;
|
||||
|
||||
const extractors = {
|
||||
name: (entry) => entry.name.toLowerCase(),
|
||||
ip: (entry) => entry.ip,
|
||||
cidr: (entry) => entry.cidr,
|
||||
used: (entry) => (entry.usage_known ? (entry.used ? 1 : 0) : -1),
|
||||
containers: (entry) => entry.containers.join(",").toLowerCase(),
|
||||
device: (entry) => entry.device.toLowerCase(),
|
||||
enabled: (entry) => (entry.enabled ? 1 : 0),
|
||||
};
|
||||
|
||||
const extract = extractors[key] || extractors.name;
|
||||
list.sort((a, b) => {
|
||||
const left = extract(a);
|
||||
const right = extract(b);
|
||||
if (left < right) {
|
||||
return -1 * multiplier;
|
||||
}
|
||||
if (left > right) {
|
||||
return 1 * multiplier;
|
||||
}
|
||||
return a.name.localeCompare(b.name) * multiplier;
|
||||
});
|
||||
|
||||
return list;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (err) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = payload.detail || `Request failed: ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
async loadInterfaces() {
|
||||
const data = await this.api("/api/interfaces");
|
||||
this.interfaces = data.items || [];
|
||||
if (!this.form.device && this.interfaces.length > 0) {
|
||||
this.form.device = this.interfaces[0];
|
||||
}
|
||||
},
|
||||
async refreshEntries() {
|
||||
this.loading = true;
|
||||
this.errorMessage = "";
|
||||
try {
|
||||
const data = await this.api("/api/refresh", { method: "POST" });
|
||||
this.entries = (data.items || []).map(mapEntry);
|
||||
this.usageKnown = Boolean(data.usage_known);
|
||||
this.usageError = data.usage_error || "";
|
||||
} catch (err) {
|
||||
this.errorMessage = err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async createEntry() {
|
||||
this.saving = true;
|
||||
this.errorMessage = "";
|
||||
try {
|
||||
await this.api("/api/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
ip: this.form.ip,
|
||||
cidr: this.form.cidr,
|
||||
device: this.form.device,
|
||||
}),
|
||||
});
|
||||
this.form.name = "";
|
||||
this.form.ip = "";
|
||||
await this.refreshEntries();
|
||||
} catch (err) {
|
||||
this.errorMessage = err.message;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
async saveEntry(entry) {
|
||||
this.errorMessage = "";
|
||||
this.busyById = { ...this.busyById, [entry.id]: true };
|
||||
try {
|
||||
await this.api(`/api/entries/${entry.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: entry.draft.name,
|
||||
ip: entry.draft.ip,
|
||||
cidr: entry.draft.cidr,
|
||||
device: entry.draft.device,
|
||||
}),
|
||||
});
|
||||
await this.refreshEntries();
|
||||
} catch (err) {
|
||||
this.errorMessage = err.message;
|
||||
} finally {
|
||||
this.busyById = { ...this.busyById, [entry.id]: false };
|
||||
}
|
||||
},
|
||||
async toggleEntry(entry) {
|
||||
this.errorMessage = "";
|
||||
this.busyById = { ...this.busyById, [entry.id]: true };
|
||||
try {
|
||||
const action = entry.enabled ? "disable" : "enable";
|
||||
await this.api(`/api/entries/${entry.id}/${action}`, { method: "POST" });
|
||||
await this.refreshEntries();
|
||||
} catch (err) {
|
||||
this.errorMessage = err.message;
|
||||
} finally {
|
||||
this.busyById = { ...this.busyById, [entry.id]: false };
|
||||
}
|
||||
},
|
||||
async deleteEntry(entry) {
|
||||
this.errorMessage = "";
|
||||
this.busyById = { ...this.busyById, [entry.id]: true };
|
||||
try {
|
||||
await this.api(`/api/entries/${entry.id}`, { method: "DELETE" });
|
||||
await this.refreshEntries();
|
||||
} catch (err) {
|
||||
this.errorMessage = err.message;
|
||||
} finally {
|
||||
this.busyById = { ...this.busyById, [entry.id]: false };
|
||||
}
|
||||
},
|
||||
containerLabel(entry) {
|
||||
if (!entry.containers || entry.containers.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
if (entry.containers.length === 1) {
|
||||
return entry.containers[0];
|
||||
}
|
||||
return `${entry.containers.join(", ")} (${entry.containers.length})`;
|
||||
},
|
||||
setSort(key) {
|
||||
if (this.sort.key === key) {
|
||||
this.sort.direction = this.sort.direction === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
this.sort.key = key;
|
||||
this.sort.direction = "asc";
|
||||
},
|
||||
sortIndicator(key) {
|
||||
if (this.sort.key !== key) {
|
||||
return "";
|
||||
}
|
||||
return this.sort.direction === "asc" ? "▲" : "▼";
|
||||
},
|
||||
isBusy(entryId) {
|
||||
return Boolean(this.busyById[entryId]);
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
await this.loadInterfaces();
|
||||
await this.refreshEntries();
|
||||
} catch (err) {
|
||||
this.errorMessage = err.message;
|
||||
}
|
||||
},
|
||||
}).mount("#app");
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Docker IP Addr Manager</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="app-shell" v-cloak>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Docker IP Addr Manager</h1>
|
||||
<p>Manage host IP addresses used for ZimaOS port bindings.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="refreshEntries" :disabled="loading">
|
||||
{{ loading ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Add IP Entry</h2>
|
||||
<form class="entry-form" @submit.prevent="createEntry">
|
||||
<label>
|
||||
Name
|
||||
<input v-model.trim="form.name" type="text" required maxlength="96" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
IP Address
|
||||
<input v-model.trim="form.ip" type="text" placeholder="10.0.4.2" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
CIDR
|
||||
<input v-model.number="form.cidr" type="number" min="0" max="32" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Device
|
||||
<select v-model="form.device" required>
|
||||
<option disabled value="">Choose device</option>
|
||||
<option v-for="iface in interfaces" :key="iface" :value="iface">{{ iface }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary" type="submit" :disabled="saving">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="status" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<div class="status warning" v-if="!usageKnown">
|
||||
Docker usage status is unknown. Disable/Delete operations are fail-closed until refresh succeeds.
|
||||
<span v-if="usageError">Error: {{ usageError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="setSort('name')">Name <span>{{ sortIndicator('name') }}</span></th>
|
||||
<th @click="setSort('ip')">IP Address <span>{{ sortIndicator('ip') }}</span></th>
|
||||
<th @click="setSort('cidr')">CIDR <span>{{ sortIndicator('cidr') }}</span></th>
|
||||
<th @click="setSort('used')">Used <span>{{ sortIndicator('used') }}</span></th>
|
||||
<th @click="setSort('containers')">Container(s) <span>{{ sortIndicator('containers') }}</span></th>
|
||||
<th @click="setSort('device')">Device <span>{{ sortIndicator('device') }}</span></th>
|
||||
<th @click="setSort('enabled')">Enabled <span>{{ sortIndicator('enabled') }}</span></th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in sortedEntries" :key="entry.id">
|
||||
<td><input v-model.trim="entry.draft.name" type="text" maxlength="96" :disabled="entry.enabled" /></td>
|
||||
<td><input v-model.trim="entry.draft.ip" type="text" :disabled="entry.enabled" /></td>
|
||||
<td><input v-model.number="entry.draft.cidr" type="number" min="0" max="32" :disabled="entry.enabled" /></td>
|
||||
<td class="status-cell">
|
||||
<span v-if="!entry.usage_known" class="chip unknown">Unknown</span>
|
||||
<span v-else-if="entry.used" class="chip used">Yes</span>
|
||||
<span v-else class="chip free">No</span>
|
||||
</td>
|
||||
<td class="containers">{{ containerLabel(entry) }}</td>
|
||||
<td>
|
||||
<select v-model="entry.draft.device" :disabled="entry.enabled">
|
||||
<option v-for="iface in interfaces" :key="`${entry.id}-${iface}`" :value="iface">{{ iface }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="toggle-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="entry.enabled ? 'btn-danger' : 'btn-primary'"
|
||||
@click="toggleEntry(entry)"
|
||||
:disabled="isBusy(entry.id)"
|
||||
>
|
||||
{{ entry.enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="saveEntry(entry)"
|
||||
:disabled="entry.enabled || isBusy(entry.id)"
|
||||
>Save</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="deleteEntry(entry)"
|
||||
:disabled="entry.enabled || isBusy(entry.id)"
|
||||
>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="sortedEntries.length === 0">
|
||||
<td colspan="8" class="empty">No entries configured.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,227 @@
|
||||
:root {
|
||||
--bg: #f2f5f8;
|
||||
--panel: #ffffff;
|
||||
--border: #d7dee7;
|
||||
--text: #1c2733;
|
||||
--muted: #5f6b77;
|
||||
--primary: #2274e5;
|
||||
--danger: #c63434;
|
||||
--warning: #8a6b00;
|
||||
--chip-used: #ffe8e8;
|
||||
--chip-free: #e7f7ec;
|
||||
--chip-unknown: #fff6d8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Sans", "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, #f4f7fb 0%, #ecf1f7 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.entry-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.55rem;
|
||||
min-height: 2.2rem;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
cursor: pointer;
|
||||
min-height: 2.2rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e8edf5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-bottom: 0.8rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
background: #e8f0fc;
|
||||
border: 1px solid #c7daf8;
|
||||
color: #2a4b7d;
|
||||
}
|
||||
|
||||
.status.warning {
|
||||
background: #fff8df;
|
||||
border-color: #f0db97;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.55rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th:last-child {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status-cell,
|
||||
.toggle-cell,
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.containers {
|
||||
color: var(--muted);
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip.used {
|
||||
background: var(--chip-used);
|
||||
color: #8a1f1f;
|
||||
}
|
||||
|
||||
.chip.free {
|
||||
background: var(--chip-free);
|
||||
color: #145a2c;
|
||||
}
|
||||
|
||||
.chip.unknown {
|
||||
background: var(--chip-unknown);
|
||||
color: #7d5b04;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import Iterable
|
||||
|
||||
from app.models import IpEntry
|
||||
|
||||
|
||||
class EntryStorage:
|
||||
def __init__(self, file_path: str):
|
||||
self._path = Path(file_path)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def list_entries(self) -> list[IpEntry]:
|
||||
with self._lock:
|
||||
return self._read_entries_unlocked()
|
||||
|
||||
def save_entries(self, entries: Iterable[IpEntry]) -> None:
|
||||
serializable = [entry.to_dict() for entry in entries]
|
||||
with self._lock:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp_path.write_text(json.dumps(serializable, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp_path.replace(self._path)
|
||||
|
||||
def _read_entries_unlocked(self) -> list[IpEntry]:
|
||||
if not self._path.exists():
|
||||
return []
|
||||
|
||||
raw = self._path.read_text(encoding="utf-8").strip()
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, list):
|
||||
raise ValueError(f"State file must be a list: {self._path}")
|
||||
|
||||
return [IpEntry.from_dict(item) for item in parsed]
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
Reference in New Issue
Block a user