Веб-интерфейс: страница /clusters, навигация и крошки для кластеров

- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
2026-04-04 13:42:21 +03:00
parent d0933510b5
commit a5d75aa56b
38 changed files with 3990 additions and 734 deletions
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Печатает в stdout путь к ``podman.sock`` или ``uid:gid`` владельца сокета.
Режимы:
* без аргументов — путь (для ``$(shell ...)`` в Makefile);
* ``--print-owner`` — ``uid:gid`` по ``stat`` найденного сокета (для ``KIND_K8S_CONTAINER_UIDGID``).
Логика пути: ``podman info`` (схема ``unix://`` снимается), ``podman machine inspect`` (без путей
в ``/var/folders/…`` на macOS — там API-сокет, **compose не смонтирует**: ``operation not supported``),
стабильные ``*.sock`` под ``~/.local/share/containers/podman/machine/``, затем типичные пути пользователя.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def _compose_cannot_bind_mount_socket(path_str: str) -> bool:
"""
На macOS сокет в ``/var/folders/…/T/podman/`` (например ``*-api.sock`` из machine inspect)
существует на диске, но bind-mount в контейнер через compose часто даёт
``mkdir … operation not supported`` — такой путь в ``CONTAINER_SOCKET`` использовать нельзя.
"""
if sys.platform != "darwin":
return False
norm = os.path.normpath(path_str)
return "/var/folders/" in norm
def _strip_unix_scheme(raw: str) -> str:
"""Путь на диске без префикса ``unix://`` / ``unix:`` (как в выводе ``podman info``)."""
s = (raw or "").strip()
if s.startswith("unix://"):
return s[7:].strip() or s
if s.startswith("unix:"):
rest = s[5:].lstrip("/")
return "/" + rest if rest else s
return s
def _podman_machine_socket_paths(podman_bin: str) -> list[str]:
"""
Пути к сокету на **хосте** из ``podman machine inspect`` (актуально для macOS/Windows).
На Linux без machine обычно возвращается ``[]``.
"""
try:
proc = subprocess.run(
[podman_bin, "machine", "inspect"],
capture_output=True,
text=True,
timeout=20,
)
if proc.returncode != 0 or not (proc.stdout or "").strip():
return []
data = json.loads(proc.stdout)
if not isinstance(data, list):
return []
out: list[str] = []
for m in data:
if not isinstance(m, dict):
continue
ci = m.get("ConnectionInfo") or {}
if not isinstance(ci, dict):
continue
ps = ci.get("PodmanSocket")
pth: str | None = None
if isinstance(ps, dict):
raw = ps.get("Path")
pth = str(raw).strip() if raw else None
elif isinstance(ps, str) and ps.strip():
pth = ps.strip()
if pth:
cleaned = _strip_unix_scheme(pth)
if not _compose_cannot_bind_mount_socket(cleaned):
out.append(cleaned)
return out
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, TypeError, ValueError):
return []
def _darwin_stable_machine_sockets() -> list[str]:
"""Подкаталог machine на macOS: сокеты вне временного ``/var/folders``."""
base = Path.home() / ".local/share/containers/podman/machine"
if sys.platform != "darwin" or not base.is_dir():
return []
out: list[str] = []
try:
for p in sorted(base.rglob("*.sock"), key=lambda x: (len(x.parts), str(x))):
s = str(p)
if _compose_cannot_bind_mount_socket(s):
continue
out.append(s)
except OSError:
return []
return out
def _podman_socket_path_for_user() -> str:
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
try:
return f"/run/user/{os.getuid()}/podman/podman.sock"
except AttributeError:
return "/run/user/1000/podman/podman.sock"
def podman_socket_owner_uidgid() -> str:
"""
UID:GID владельца найденного ``podman.sock`` на диске (``stat``).
Нужен для ``user:`` в compose: при Podman на macOS и в VM владелец сокета часто
**не совпадает** с ``id -u`` / ``id -g`` процесса ``make`` на хосте — иначе
``permission denied`` на смонтированный сокет внутри контейнера.
Если сокет недоступен — fallback на UID:GID текущего процесса.
"""
path_str = detect_podman_socket_path()
p = Path(path_str)
try:
if p.is_socket():
st = p.stat()
return f"{st.st_uid}:{st.st_gid}"
except OSError:
pass
try:
return f"{os.getuid()}:{os.getgid()}"
except AttributeError:
return "1000:1000"
def detect_podman_socket_path() -> str:
"""Путь к существующему сокету или типичный путь по умолчанию."""
cands: list[str] = []
podman_bin = shutil.which("podman")
if podman_bin:
try:
proc = subprocess.run(
[podman_bin, "info", "-f", "{{.Host.RemoteSocket.Path}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
candidate = (line[0] if line else "").strip()
if candidate and "<no value>" not in candidate.lower():
cands.append(_strip_unix_scheme(candidate))
except (OSError, subprocess.TimeoutExpired):
pass
# Реальный файл на хосте (без macOS temp api.sock — см. _compose_cannot_bind_mount_socket).
cands.extend(_podman_machine_socket_paths(podman_bin))
cands.extend(_darwin_stable_machine_sockets())
cands.append(_podman_socket_path_for_user())
for path in cands:
cleaned = _strip_unix_scheme(path)
if _compose_cannot_bind_mount_socket(cleaned):
continue
p = Path(cleaned).expanduser()
try:
if p.is_socket():
return str(p.resolve())
except OSError:
continue
return str(Path(_strip_unix_scheme(_podman_socket_path_for_user())).expanduser())
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--print-owner":
sys.stdout.write(podman_socket_owner_uidgid())
return
sys.stdout.write(detect_podman_socket_path())
if __name__ == "__main__":
main()