441 lines
16 KiB
Python
441 lines
16 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import asyncio
|
||
|
|
import ipaddress
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import posixpath
|
||
|
|
import time
|
||
|
|
from datetime import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
from stat import S_ISDIR
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
try:
|
||
|
|
import paramiko
|
||
|
|
except ImportError: # pragma: no cover - exercised indirectly via status checks
|
||
|
|
paramiko = None
|
||
|
|
|
||
|
|
from services import settings as settings_service
|
||
|
|
|
||
|
|
DEFAULT_REMOTE_APP_DIR = "/share/Docker/homelabtoolkit"
|
||
|
|
DEPLOY_TIMEOUT_SECONDS = int(os.environ.get("DEPLOY_TIMEOUT_SECONDS", "1800"))
|
||
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||
|
|
REMOTE_CONTAINER_NAME = os.environ.get("DEPLOY_CONTAINER_NAME", "homelabtoolkit").strip() or "homelabtoolkit"
|
||
|
|
|
||
|
|
TOP_LEVEL_FILES = ("app.py", "rotate_preroll.py", "Dockerfile", "docker-compose.yml", "requirements.txt")
|
||
|
|
FRONTEND_ROOT_FILES = ("package.json", "package-lock.json", "vite.config.ts", "tsconfig.json", "index.html")
|
||
|
|
LOGO_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
||
|
|
|
||
|
|
runtime: dict[str, Any] = {
|
||
|
|
"running": False,
|
||
|
|
"last_started_at": None,
|
||
|
|
"last_finished_at": None,
|
||
|
|
"last_status": "idle",
|
||
|
|
"last_message": None,
|
||
|
|
"last_output_tail": [],
|
||
|
|
}
|
||
|
|
_lock = asyncio.Lock()
|
||
|
|
_task: asyncio.Task | None = None
|
||
|
|
|
||
|
|
logging.getLogger("paramiko").setLevel(logging.WARNING)
|
||
|
|
logger = logging.getLogger("homelabtoolkit.update")
|
||
|
|
|
||
|
|
|
||
|
|
def _now() -> str:
|
||
|
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||
|
|
|
||
|
|
|
||
|
|
def normalize_client_host(value: str | None) -> str:
|
||
|
|
text = (value or "").strip()
|
||
|
|
if not text:
|
||
|
|
return ""
|
||
|
|
if "," in text:
|
||
|
|
text = text.split(",", 1)[0].strip()
|
||
|
|
if text.startswith("::ffff:"):
|
||
|
|
text = text[len("::ffff:") :]
|
||
|
|
return text
|
||
|
|
|
||
|
|
|
||
|
|
def is_local_client(host: str | None) -> bool:
|
||
|
|
text = normalize_client_host(host)
|
||
|
|
if not text:
|
||
|
|
return False
|
||
|
|
if text.lower() == "localhost":
|
||
|
|
return True
|
||
|
|
try:
|
||
|
|
ip = ipaddress.ip_address(text)
|
||
|
|
except ValueError:
|
||
|
|
return False
|
||
|
|
return ip.is_loopback or ip.is_private
|
||
|
|
|
||
|
|
|
||
|
|
def is_paramiko_available() -> bool:
|
||
|
|
return paramiko is not None
|
||
|
|
|
||
|
|
|
||
|
|
def is_configured(values: dict[str, Any]) -> bool:
|
||
|
|
return bool(str(values.get("deploy_nas_host") or "").strip() and str(values.get("deploy_nas_user") or "").strip())
|
||
|
|
|
||
|
|
|
||
|
|
def remote_app_dir(values: dict[str, Any]) -> str:
|
||
|
|
return str(values.get("deploy_remote_app_dir") or DEFAULT_REMOTE_APP_DIR).strip() or DEFAULT_REMOTE_APP_DIR
|
||
|
|
|
||
|
|
|
||
|
|
def deploy_password(values: dict[str, Any]) -> str:
|
||
|
|
return str(values.get("deploy_nas_password") or "")
|
||
|
|
|
||
|
|
|
||
|
|
def using_saved_password(values: dict[str, Any]) -> bool:
|
||
|
|
return bool(deploy_password(values))
|
||
|
|
|
||
|
|
|
||
|
|
def deploy_music_host_path(values: dict[str, Any]) -> str:
|
||
|
|
return str(values.get("deploy_music_host_path") or "/share/Music").strip() or "/share/Music"
|
||
|
|
|
||
|
|
|
||
|
|
def _remote(values: dict[str, Any]) -> str:
|
||
|
|
return f'{str(values.get("deploy_nas_user") or "").strip()}@{str(values.get("deploy_nas_host") or "").strip()}'
|
||
|
|
|
||
|
|
|
||
|
|
def _tail(lines: list[str], max_lines: int = 40) -> list[str]:
|
||
|
|
return lines[-max_lines:]
|
||
|
|
|
||
|
|
|
||
|
|
def _push_runtime_output(log_lines: list[str]) -> None:
|
||
|
|
runtime["last_output_tail"] = list(_tail(log_lines))
|
||
|
|
|
||
|
|
|
||
|
|
def _log(log_lines: list[str], message: str) -> None:
|
||
|
|
text = message.rstrip()
|
||
|
|
if not text:
|
||
|
|
return
|
||
|
|
log_lines.append(text)
|
||
|
|
_push_runtime_output(log_lines)
|
||
|
|
logger.info(text)
|
||
|
|
|
||
|
|
|
||
|
|
def _ensure_remote_dir(sftp, remote_dir: str) -> None:
|
||
|
|
normalized = posixpath.normpath(remote_dir)
|
||
|
|
parts = [part for part in normalized.split("/") if part]
|
||
|
|
current = "/" if normalized.startswith("/") else ""
|
||
|
|
for part in parts:
|
||
|
|
current = posixpath.join(current, part) if current not in ("", "/") else f"{current}{part}" if current == "/" else part
|
||
|
|
try:
|
||
|
|
attrs = sftp.stat(current)
|
||
|
|
if not S_ISDIR(attrs.st_mode):
|
||
|
|
raise RuntimeError(f"Remote path exists but is not a directory: {current}")
|
||
|
|
except OSError:
|
||
|
|
sftp.mkdir(current)
|
||
|
|
|
||
|
|
|
||
|
|
def _upload_file(sftp, local_path: Path, remote_path: str, log_lines: list[str]) -> None:
|
||
|
|
_ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
||
|
|
sftp.put(str(local_path), remote_path)
|
||
|
|
_log(log_lines, f"Uploaded {local_path.relative_to(ROOT_DIR).as_posix()} -> {remote_path}")
|
||
|
|
|
||
|
|
|
||
|
|
def _upload_text(sftp, content: str, remote_path: str, label: str, log_lines: list[str]) -> None:
|
||
|
|
_ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
||
|
|
with sftp.file(remote_path, "w") as handle:
|
||
|
|
handle.write(content)
|
||
|
|
_log(log_lines, f"Rendered {label} -> {remote_path}")
|
||
|
|
|
||
|
|
|
||
|
|
def _upload_tree(sftp, local_dir: Path, remote_dir: str, log_lines: list[str]) -> None:
|
||
|
|
if not local_dir.exists():
|
||
|
|
return
|
||
|
|
_ensure_remote_dir(sftp, remote_dir)
|
||
|
|
for path in sorted(local_dir.rglob("*")):
|
||
|
|
if path.is_dir():
|
||
|
|
_ensure_remote_dir(sftp, posixpath.join(remote_dir, path.relative_to(local_dir).as_posix()))
|
||
|
|
continue
|
||
|
|
if "__pycache__" in path.parts:
|
||
|
|
continue
|
||
|
|
remote_path = posixpath.join(remote_dir, path.relative_to(local_dir).as_posix())
|
||
|
|
_upload_file(sftp, path, remote_path, log_lines)
|
||
|
|
|
||
|
|
|
||
|
|
def _stream_output(channel, log_lines: list[str]) -> None:
|
||
|
|
stdout_buffer = ""
|
||
|
|
stderr_buffer = ""
|
||
|
|
while True:
|
||
|
|
had_output = False
|
||
|
|
if channel.recv_ready():
|
||
|
|
stdout_buffer += channel.recv(4096).decode("utf-8", errors="replace")
|
||
|
|
had_output = True
|
||
|
|
while "\n" in stdout_buffer:
|
||
|
|
line, stdout_buffer = stdout_buffer.split("\n", 1)
|
||
|
|
_log(log_lines, line)
|
||
|
|
if channel.recv_stderr_ready():
|
||
|
|
stderr_buffer += channel.recv_stderr(4096).decode("utf-8", errors="replace")
|
||
|
|
had_output = True
|
||
|
|
while "\n" in stderr_buffer:
|
||
|
|
line, stderr_buffer = stderr_buffer.split("\n", 1)
|
||
|
|
_log(log_lines, line)
|
||
|
|
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
|
||
|
|
break
|
||
|
|
if not had_output:
|
||
|
|
time.sleep(0.1)
|
||
|
|
|
||
|
|
if stdout_buffer.strip():
|
||
|
|
_log(log_lines, stdout_buffer)
|
||
|
|
if stderr_buffer.strip():
|
||
|
|
_log(log_lines, stderr_buffer)
|
||
|
|
|
||
|
|
|
||
|
|
def _run_remote_command(client, command: str, log_lines: list[str], allow_failure: bool = False) -> tuple[int, list[str]]:
|
||
|
|
stdin, stdout, stderr = client.exec_command(command, timeout=DEPLOY_TIMEOUT_SECONDS)
|
||
|
|
if stdin:
|
||
|
|
stdin.close()
|
||
|
|
channel = stdout.channel
|
||
|
|
command_lines: list[str] = []
|
||
|
|
_stream_output(channel, command_lines)
|
||
|
|
for line in command_lines:
|
||
|
|
_log(log_lines, line)
|
||
|
|
exit_code = stdout.channel.recv_exit_status()
|
||
|
|
if exit_code != 0 and not allow_failure:
|
||
|
|
raise RuntimeError(f"Remote command failed with exit code {exit_code}")
|
||
|
|
return exit_code, command_lines
|
||
|
|
|
||
|
|
|
||
|
|
def _connect(values: dict[str, Any]):
|
||
|
|
if paramiko is None:
|
||
|
|
raise RuntimeError("Paramiko is not installed.")
|
||
|
|
client = paramiko.SSHClient()
|
||
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||
|
|
connect_kwargs: dict[str, Any] = {
|
||
|
|
"hostname": str(values.get("deploy_nas_host") or "").strip(),
|
||
|
|
"username": str(values.get("deploy_nas_user") or "").strip(),
|
||
|
|
"timeout": 20,
|
||
|
|
"banner_timeout": 20,
|
||
|
|
"auth_timeout": 20,
|
||
|
|
}
|
||
|
|
password = deploy_password(values)
|
||
|
|
if password:
|
||
|
|
connect_kwargs["password"] = password
|
||
|
|
connect_kwargs["look_for_keys"] = False
|
||
|
|
connect_kwargs["allow_agent"] = False
|
||
|
|
client.connect(**connect_kwargs)
|
||
|
|
return client
|
||
|
|
|
||
|
|
|
||
|
|
def render_remote_compose(values: dict[str, Any]) -> str:
|
||
|
|
source = (ROOT_DIR / "docker-compose.yml").read_text(encoding="utf-8")
|
||
|
|
remote_dir = remote_app_dir(values).rstrip("/")
|
||
|
|
music_host = deploy_music_host_path(values)
|
||
|
|
rendered = source
|
||
|
|
rendered = rendered.replace("/share/Docker/homelabtoolkit/output:/app/output", f"{remote_dir}/output:/app/output")
|
||
|
|
rendered = rendered.replace("/share/Docker/homelabtoolkit/cache:/app/cache", f"{remote_dir}/cache:/app/cache")
|
||
|
|
rendered = rendered.replace("/share/Music:/music", f"{music_host}:/music")
|
||
|
|
return rendered
|
||
|
|
|
||
|
|
|
||
|
|
def render_remote_settings(values: dict[str, Any]) -> str:
|
||
|
|
payload = {key: values.get(key) for key in settings_service.FIELD_SPECS if key in values}
|
||
|
|
return json.dumps(payload, indent=2)
|
||
|
|
|
||
|
|
|
||
|
|
def _check_container_health(client, base_dir: str, log_lines: list[str]) -> tuple[bool, str]:
|
||
|
|
inspect_cmd = (
|
||
|
|
f"docker inspect -f '{{{{.State.Status}}}}|{{{{.State.Running}}}}|{{{{.State.ExitCode}}}}|{{{{.State.Error}}}}' "
|
||
|
|
f"{REMOTE_CONTAINER_NAME} 2>/dev/null || true"
|
||
|
|
)
|
||
|
|
_, lines = _run_remote_command(client, inspect_cmd, log_lines, allow_failure=True)
|
||
|
|
raw = (lines[-1] if lines else "").strip()
|
||
|
|
if not raw:
|
||
|
|
_log(log_lines, f"Container {REMOTE_CONTAINER_NAME} was not found after deploy.")
|
||
|
|
return False, "container missing"
|
||
|
|
|
||
|
|
parts = raw.split("|", 3)
|
||
|
|
state = parts[0] if len(parts) > 0 else "unknown"
|
||
|
|
running = parts[1].lower() == "true" if len(parts) > 1 else False
|
||
|
|
exit_code = parts[2] if len(parts) > 2 else ""
|
||
|
|
error = parts[3].strip() if len(parts) > 3 else ""
|
||
|
|
_log(log_lines, f"Container status: state={state} running={running} exit_code={exit_code or 'n/a'}")
|
||
|
|
if running and state == "running":
|
||
|
|
return True, state
|
||
|
|
|
||
|
|
_log(log_lines, "Container did not reach running state. Fetching recent logs…")
|
||
|
|
_run_remote_command(client, f"docker logs --tail 80 {REMOTE_CONTAINER_NAME} 2>&1 || true", log_lines, allow_failure=True)
|
||
|
|
if error:
|
||
|
|
_log(log_lines, f"Container error: {error}")
|
||
|
|
return False, state or "unknown"
|
||
|
|
|
||
|
|
|
||
|
|
def status_payload(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
|
||
|
|
allowed = is_local_client(client_host)
|
||
|
|
configured = is_configured(values)
|
||
|
|
transport_ready = is_paramiko_available()
|
||
|
|
available = allowed and configured and transport_ready
|
||
|
|
|
||
|
|
reasons: list[str] = []
|
||
|
|
if not allowed:
|
||
|
|
reasons.append("Updates can only be triggered from a local/private network client.")
|
||
|
|
if not configured:
|
||
|
|
reasons.append("Set a NAS host and NAS user first.")
|
||
|
|
if not transport_ready:
|
||
|
|
reasons.append("Python SSH support is not installed on this host yet.")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"available": available,
|
||
|
|
"allowed": allowed,
|
||
|
|
"configured": configured,
|
||
|
|
"transport": "paramiko" if transport_ready else None,
|
||
|
|
"transport_ready": transport_ready,
|
||
|
|
"password_configured": using_saved_password(values),
|
||
|
|
"client_host": normalize_client_host(client_host),
|
||
|
|
"nas_host": str(values.get("deploy_nas_host") or "").strip(),
|
||
|
|
"nas_user": str(values.get("deploy_nas_user") or "").strip(),
|
||
|
|
"remote_app_dir": remote_app_dir(values),
|
||
|
|
"runtime": dict(runtime),
|
||
|
|
"reason": " ".join(reasons).strip() or None,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _run_sync(values: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
log_lines: list[str] = []
|
||
|
|
client = None
|
||
|
|
sftp = None
|
||
|
|
try:
|
||
|
|
_log(log_lines, f"Deploying HomelabToolkit to {_remote(values)}:{remote_app_dir(values)}")
|
||
|
|
_log(
|
||
|
|
log_lines,
|
||
|
|
"Auth mode: saved password" if using_saved_password(values) else "Auth mode: SSH keys / agent"
|
||
|
|
)
|
||
|
|
_log(log_lines, "Connecting to remote host…")
|
||
|
|
|
||
|
|
client = _connect(values)
|
||
|
|
_log(log_lines, "SSH connection established.")
|
||
|
|
sftp = client.open_sftp()
|
||
|
|
_log(log_lines, "SFTP channel opened.")
|
||
|
|
|
||
|
|
base_dir = remote_app_dir(values).rstrip("/")
|
||
|
|
for directory in (
|
||
|
|
base_dir,
|
||
|
|
f"{base_dir}/output",
|
||
|
|
f"{base_dir}/cache",
|
||
|
|
f"{base_dir}/static",
|
||
|
|
f"{base_dir}/static/studios",
|
||
|
|
f"{base_dir}/services",
|
||
|
|
f"{base_dir}/frontend",
|
||
|
|
):
|
||
|
|
_ensure_remote_dir(sftp, directory)
|
||
|
|
_log(log_lines, "Remote directory structure ensured.")
|
||
|
|
|
||
|
|
for name in TOP_LEVEL_FILES:
|
||
|
|
source = ROOT_DIR / name
|
||
|
|
if source.exists():
|
||
|
|
if name == "docker-compose.yml":
|
||
|
|
_upload_text(sftp, render_remote_compose(values), f"{base_dir}/{name}", name, log_lines)
|
||
|
|
else:
|
||
|
|
_upload_file(sftp, source, f"{base_dir}/{name}", log_lines)
|
||
|
|
|
||
|
|
_upload_text(sftp, render_remote_settings(values), f"{base_dir}/cache/settings.json", "settings.json", log_lines)
|
||
|
|
|
||
|
|
_upload_tree(sftp, ROOT_DIR / "static", f"{base_dir}/static", log_lines)
|
||
|
|
_upload_tree(sftp, ROOT_DIR / "services", f"{base_dir}/services", log_lines)
|
||
|
|
|
||
|
|
_log(log_lines, "Cleaning remote frontend build directories…")
|
||
|
|
_run_remote_command(client, f"rm -rf {base_dir}/frontend/node_modules {base_dir}/frontend/dist", log_lines)
|
||
|
|
for name in FRONTEND_ROOT_FILES:
|
||
|
|
source = ROOT_DIR / "frontend" / name
|
||
|
|
if source.exists():
|
||
|
|
_upload_file(sftp, source, f"{base_dir}/frontend/{name}", log_lines)
|
||
|
|
_upload_tree(sftp, ROOT_DIR / "frontend" / "src", f"{base_dir}/frontend/src", log_lines)
|
||
|
|
|
||
|
|
for path in sorted(ROOT_DIR.iterdir()):
|
||
|
|
if path.is_file() and path.suffix.lower() in LOGO_EXTENSIONS:
|
||
|
|
_upload_file(sftp, path, f"{base_dir}/{path.name}", log_lines)
|
||
|
|
|
||
|
|
_log(log_lines, "Starting remote docker compose build and update…")
|
||
|
|
_run_remote_command(
|
||
|
|
client,
|
||
|
|
(
|
||
|
|
f"cd {base_dir} && "
|
||
|
|
"if command -v docker-compose >/dev/null 2>&1; then "
|
||
|
|
"docker-compose build && docker-compose up -d; "
|
||
|
|
"else "
|
||
|
|
"docker compose build && docker compose up -d; "
|
||
|
|
"fi"
|
||
|
|
),
|
||
|
|
log_lines,
|
||
|
|
)
|
||
|
|
|
||
|
|
healthy, state = _check_container_health(client, base_dir, log_lines)
|
||
|
|
if not healthy:
|
||
|
|
return {
|
||
|
|
"ok": False,
|
||
|
|
"message": f"Deployment finished but container state is {state}.",
|
||
|
|
"output_tail": _tail(log_lines),
|
||
|
|
}
|
||
|
|
|
||
|
|
_log(log_lines, "Deployment complete.")
|
||
|
|
_log(log_lines, f"To view logs: ssh {_remote(values)} 'cd {base_dir} && docker compose logs -f'")
|
||
|
|
return {
|
||
|
|
"ok": True,
|
||
|
|
"message": "Deployment completed.",
|
||
|
|
"output_tail": _tail(log_lines),
|
||
|
|
}
|
||
|
|
except Exception as exc:
|
||
|
|
return {
|
||
|
|
"ok": False,
|
||
|
|
"message": f"Deployment failed: {exc}",
|
||
|
|
"output_tail": _tail(log_lines),
|
||
|
|
}
|
||
|
|
finally:
|
||
|
|
if sftp is not None:
|
||
|
|
sftp.close()
|
||
|
|
if client is not None:
|
||
|
|
client.close()
|
||
|
|
|
||
|
|
|
||
|
|
async def run_update(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
|
||
|
|
async with _lock:
|
||
|
|
status = status_payload(values, client_host)
|
||
|
|
if not status["allowed"]:
|
||
|
|
return {"ok": False, "message": status["reason"] or "Update not allowed."}
|
||
|
|
if not status["configured"]:
|
||
|
|
return {"ok": False, "message": status["reason"] or "Update target is not configured."}
|
||
|
|
if not status["transport_ready"]:
|
||
|
|
return {"ok": False, "message": status["reason"] or "Python SSH support is not available."}
|
||
|
|
|
||
|
|
runtime["running"] = True
|
||
|
|
runtime["last_started_at"] = _now()
|
||
|
|
runtime["last_finished_at"] = None
|
||
|
|
runtime["last_status"] = "running"
|
||
|
|
runtime["last_message"] = None
|
||
|
|
runtime["last_output_tail"] = []
|
||
|
|
|
||
|
|
try:
|
||
|
|
result = await asyncio.wait_for(asyncio.to_thread(_run_sync, values), timeout=DEPLOY_TIMEOUT_SECONDS)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
result = {
|
||
|
|
"ok": False,
|
||
|
|
"message": f"Deployment timed out after {DEPLOY_TIMEOUT_SECONDS} seconds.",
|
||
|
|
"output_tail": [],
|
||
|
|
}
|
||
|
|
|
||
|
|
runtime["running"] = False
|
||
|
|
runtime["last_finished_at"] = _now()
|
||
|
|
runtime["last_status"] = "ok" if result.get("ok") else "error"
|
||
|
|
runtime["last_message"] = result.get("message")
|
||
|
|
runtime["last_output_tail"] = list(result.get("output_tail") or [])
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
async def start_update(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
|
||
|
|
global _task
|
||
|
|
status = status_payload(values, client_host)
|
||
|
|
if not status["allowed"]:
|
||
|
|
return {"ok": False, "message": status["reason"] or "Update not allowed."}
|
||
|
|
if not status["configured"]:
|
||
|
|
return {"ok": False, "message": status["reason"] or "Update target is not configured."}
|
||
|
|
if not status["transport_ready"]:
|
||
|
|
return {"ok": False, "message": status["reason"] or "Python SSH support is not available."}
|
||
|
|
if runtime["running"]:
|
||
|
|
return {"ok": False, "message": "Deployment is already running."}
|
||
|
|
_task = asyncio.create_task(run_update(values, client_host))
|
||
|
|
return {"ok": True, "message": "Deployment started."}
|