Files
kiauh/kiauh/components/webui_client/client_utils.py

486 lines
16 KiB
Python

# ======================================================================= #
# Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# #
# This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import json
import re
import shutil
from json import JSONDecodeError
from pathlib import Path
from subprocess import PIPE, CalledProcessError, run
from typing import List, get_args
from components.klipper.klipper import Klipper
from components.webui_client import MODULE_PATH
from components.webui_client.base_data import (
BaseWebClient,
WebClientType,
)
from components.webui_client.client_dialogs import print_client_port_select_dialog
from components.webui_client.fluidd_data import FluiddData
from components.webui_client.mainsail_data import MainsailData
from core.constants import (
NGINX_CONFD,
NGINX_SITES_AVAILABLE,
NGINX_SITES_ENABLED,
)
from core.logger import Logger
from core.services.backup_service import BackupService
from core.settings.kiauh_settings import KiauhSettings, WebUiSettings
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
SimpleConfigParser,
)
from core.types.color import Color
from core.types.component_status import ComponentStatus
from utils.common import get_install_status
from utils.fs_utils import create_symlink, remove_file
from utils.git_utils import (
get_latest_remote_tag,
get_latest_unstable_tag,
)
from utils.input_utils import get_number_input
from utils.instance_utils import get_instances
def get_client_status(
client: BaseWebClient, fetch_remote: bool = False
) -> ComponentStatus:
files = [
NGINX_SITES_AVAILABLE.joinpath(client.name),
NGINX_CONFD.joinpath("upstreams.conf"),
NGINX_CONFD.joinpath("common_vars.conf"),
]
comp_status: ComponentStatus = get_install_status(client.client_dir, files=files)
# if the client dir does not exist, set the status to not
# installed even if the other files are present
if not client.client_dir.exists():
comp_status.status = 0
comp_status.local = get_local_client_version(client)
comp_status.remote = get_remote_client_version(client) if fetch_remote else None
return comp_status
def get_client_config_status(client: BaseWebClient) -> ComponentStatus:
return get_install_status(client.client_config.config_dir)
def get_current_client_config() -> str:
mainsail, fluidd = MainsailData(), FluiddData()
clients: List[BaseWebClient] = [mainsail, fluidd]
installed = [c for c in clients if c.client_config.config_dir.exists()]
if not installed:
return Color.apply("-", Color.CYAN)
elif len(installed) == 1:
cfg = installed[0].client_config
return Color.apply(cfg.display_name, Color.CYAN)
# at this point, both client config folders exists, so we need to check
# which are actually included in the printer.cfg of all klipper instances
mainsail_includes, fluidd_includes = [], []
klipper_instances: List[Klipper] = get_instances(Klipper)
for instance in klipper_instances:
scp = SimpleConfigParser()
scp.read_file(instance.cfg_file)
includes_mainsail = scp.has_section(mainsail.client_config.config_section)
includes_fluidd = scp.has_section(fluidd.client_config.config_section)
if includes_mainsail:
mainsail_includes.append(instance)
if includes_fluidd:
fluidd_includes.append(instance)
# if both are included in the same file, we have a potential conflict
if includes_mainsail and includes_fluidd:
return Color.apply("Conflict", Color.YELLOW)
if not mainsail_includes and not fluidd_includes:
# there are no includes at all, even though the client config folders exist
return Color.apply("-", Color.CYAN)
elif len(fluidd_includes) > len(mainsail_includes):
# there are more instances that include fluidd than mainsail
return Color.apply(fluidd.client_config.display_name, Color.CYAN)
else:
# there are the same amount of non-conflicting includes for each config
# or more instances include mainsail than fluidd
return Color.apply(mainsail.client_config.display_name, Color.CYAN)
def enable_mainsail_remotemode() -> None:
Logger.print_status("Enable Mainsails remote mode ...")
c_json = MainsailData().client_dir.joinpath("config.json")
with open(c_json, "r") as f:
config_data = json.load(f)
if config_data["instancesDB"] == "browser" or config_data["instancesDB"] == "json":
Logger.print_info("Remote mode already configured. Skipped ...")
return
Logger.print_status("Setting instance storage location to 'browser' ...")
config_data["instancesDB"] = "browser"
with open(c_json, "w") as f:
json.dump(config_data, f, indent=4)
Logger.print_ok("Mainsails remote mode enabled!")
def symlink_webui_nginx_log(
client: BaseWebClient, klipper_instances: List[Klipper]
) -> None:
Logger.print_status("Link NGINX logs into log directory ...")
access_log = client.nginx_access_log
error_log = client.nginx_error_log
for instance in klipper_instances:
desti_access = instance.base.log_dir.joinpath(access_log.name)
if not desti_access.exists():
desti_access.symlink_to(access_log)
desti_error = instance.base.log_dir.joinpath(error_log.name)
if not desti_error.exists():
desti_error.symlink_to(error_log)
def get_local_client_version(client: BaseWebClient) -> str | None:
relinfo_file = client.client_dir.joinpath("release_info.json")
version_file = client.client_dir.joinpath(".version")
default = "n/a"
if not client.client_dir.exists():
return default
# try to get version from release_info.json first
if relinfo_file.is_file():
try:
if relinfo_file.stat().st_size == 0:
raise JSONDecodeError("Empty file", "", 0)
with open(relinfo_file, "r", encoding="utf-8") as f:
data = json.load(f)
raw_version = data.get("version")
if raw_version is not None:
parsed = str(raw_version).strip()
if parsed:
return parsed
except (JSONDecodeError, OSError):
Logger.print_error("Invalid 'release_info.json'")
# fallback to .version file
if version_file.is_file():
try:
with open(version_file, "r") as f:
line = f.readline().strip()
return line or default
except OSError:
Logger.print_error("Unable to read '.version'")
return default
def get_remote_client_version(client: BaseWebClient) -> str | None:
try:
if (tag := get_latest_remote_tag(client.repo_path)) != "":
return str(tag)
return None
except Exception:
return None
def backup_client_data(client: BaseWebClient) -> None:
version = ""
src = client.client_dir
if src.joinpath(".version").exists():
with open(src.joinpath(".version"), "r") as v:
version = v.readlines()[0]
svc = BackupService()
target_path = svc.backup_root.joinpath(f"{client.client_dir.name}_{version}")
svc.backup_directory(
source_path=client.client_dir,
target_path=target_path,
backup_name=client.name,
)
svc.backup_file(
source_path=client.config_file,
target_path=target_path,
)
def backup_client_config_data(client: BaseWebClient) -> None:
version = ""
src = client.client_dir
if src.joinpath(".version").exists():
with open(src.joinpath(".version"), "r") as v:
version = v.readlines()[0]
svc = BackupService()
target_path = svc.backup_root.joinpath(f"{client.client_dir.name}_{version}")
svc.backup_directory(
source_path=client.client_config.config_dir,
target_path=target_path,
backup_name=client.client_config.name,
)
def get_existing_clients() -> List[BaseWebClient]:
clients = list(get_args(WebClientType))
installed_clients: List[BaseWebClient] = []
for client in clients:
if client.client_dir.exists():
installed_clients.append(client)
return installed_clients
def detect_client_cfg_conflict(curr_client: BaseWebClient) -> bool:
"""
Check if any other client configs are present on the system.
It is usually not harmful, but chances are they can conflict each other.
Multiple client configs are, at least, redundant to have them installed
:param curr_client: The client name to check for the conflict
:return: True, if other client configs were found, else False
"""
mainsail_cfg_status: ComponentStatus = get_client_config_status(MainsailData())
fluidd_cfg_status: ComponentStatus = get_client_config_status(FluiddData())
if curr_client.client == WebClientType.MAINSAIL and fluidd_cfg_status.status == 2:
return True
if curr_client.client == WebClientType.FLUIDD and mainsail_cfg_status.status == 2:
return True
return False
def get_download_url(base_url: str, client: BaseWebClient) -> str:
settings = KiauhSettings()
use_unstable = settings.get(client.name, "unstable_releases")
stable_url = f"{base_url}/latest/download/{client.name}.zip"
if not use_unstable:
return stable_url
try:
unstable_tag = get_latest_unstable_tag(client.repo_path)
if unstable_tag == "":
raise Exception
return f"{base_url}/download/{unstable_tag}/{client.name}.zip"
except Exception:
return stable_url
#################################################
## NGINX RELATED FUNCTIONS
#################################################
def copy_upstream_nginx_cfg() -> None:
"""
Creates an upstream.conf in /etc/nginx/conf.d
:return: None
"""
source = MODULE_PATH.joinpath("assets/upstreams.conf")
target = NGINX_CONFD.joinpath("upstreams.conf")
try:
command = ["sudo", "cp", source, target]
run(command, stderr=PIPE, check=True)
except CalledProcessError as e:
log = f"Unable to create upstreams.conf: {e.stderr.decode()}"
Logger.print_error(log)
raise
def copy_common_vars_nginx_cfg() -> None:
"""
Creates a common_vars.conf in /etc/nginx/conf.d
:return: None
"""
source = MODULE_PATH.joinpath("assets/common_vars.conf")
target = NGINX_CONFD.joinpath("common_vars.conf")
try:
command = ["sudo", "cp", source, target]
run(command, stderr=PIPE, check=True)
except CalledProcessError as e:
log = f"Unable to create upstreams.conf: {e.stderr.decode()}"
Logger.print_error(log)
raise
def generate_nginx_cfg_from_template(name: str, template_src: Path, **kwargs) -> None:
"""
Creates an NGINX config from a template file and
replaces all placeholders passed as kwargs. A placeholder must be defined
in the template file as %{placeholder}%.
:param name: name of the config to create
:param template_src: the path to the template file
:return: None
"""
tmp = Path.home().joinpath(f"{name}.tmp")
shutil.copy(template_src, tmp)
with open(tmp, "r+") as f:
content = f.read()
for key, value in kwargs.items():
content = content.replace(f"%{key}%", str(value))
f.seek(0)
f.write(content)
f.truncate()
target = NGINX_SITES_AVAILABLE.joinpath(name)
try:
command = ["sudo", "mv", tmp, target]
run(command, stderr=PIPE, check=True)
except CalledProcessError as e:
log = f"Unable to create '{target}': {e.stderr.decode()}"
Logger.print_error(log)
raise
def create_nginx_cfg(
display_name: str,
cfg_name: str,
template_src: Path,
**kwargs,
) -> None:
from utils.sys_utils import set_nginx_permissions
try:
Logger.print_status(f"Creating NGINX config for {display_name} ...")
source = NGINX_SITES_AVAILABLE.joinpath(cfg_name)
target = NGINX_SITES_ENABLED.joinpath(cfg_name)
remove_file(Path("/etc/nginx/sites-enabled/default"), True)
generate_nginx_cfg_from_template(cfg_name, template_src=template_src, **kwargs)
create_symlink(source, target, True)
set_nginx_permissions()
Logger.print_ok(f"NGINX config for {display_name} successfully created.")
except Exception:
Logger.print_error(f"Creating NGINX config for {display_name} failed!")
raise
def get_nginx_config_list() -> List[Path]:
"""
Get a list of all NGINX config files in /etc/nginx/sites-enabled
:return: List of NGINX config files
"""
configs: List[Path] = []
for config in NGINX_SITES_ENABLED.iterdir():
if not config.is_file():
continue
configs.append(config)
return configs
def get_nginx_listen_port(config: Path) -> int | None:
"""
Get the listen port from an NGINX config file
:param config: The NGINX config file to read the port from
:return: The listen port as int or None if not found/parsable
"""
# noinspection HttpUrlsUsage
pattern = r"default_server|http://|https://|[;\[\]]"
port = ""
with open(config, "r") as cfg:
for line in cfg.readlines():
line = re.sub(pattern, "", line.strip())
if line.startswith("listen"):
if ":" not in line:
port = line.split()[-1]
else:
port = line.split(":")[-1]
try:
return int(port)
except ValueError:
Logger.print_error(
f"Unable to parse listen port {port} from {config.name}!"
)
return None
def read_ports_from_nginx_configs() -> List[int]:
"""
Helper function to iterate over all NGINX configs
and read all ports defined for listen
:return: A sorted list of listen ports
"""
if not NGINX_SITES_ENABLED.exists():
return []
port_list: List[int] = []
for config in get_nginx_config_list():
port = get_nginx_listen_port(config)
if port is not None:
port_list.append(port)
return sorted(port_list, key=lambda x: int(x))
def get_client_port_selection(
client: BaseWebClient,
settings: KiauhSettings,
reconfigure=False,
) -> int:
default_port: int = int(settings.get(client.name, "port"))
ports_in_use: List[int] = read_ports_from_nginx_configs()
next_free_port: int = get_next_free_port(ports_in_use)
port: int = (
next_free_port
if not reconfigure and default_port in ports_in_use
else default_port
)
print_client_port_select_dialog(client.display_name, port, ports_in_use)
while True:
_type = "Reconfigure" if reconfigure else "Configure"
question = f"{_type} {client.display_name} for port"
port_input = get_number_input(question, min_value=80, default=port)
if port_input not in ports_in_use:
client_settings: WebUiSettings = settings[client.name]
client_settings.port = port_input
settings.save()
return port_input
Logger.print_error("This port is already in use. Please select another one.")
def get_next_free_port(ports_in_use: List[int]) -> int:
valid_ports = set(range(80, 7125))
used_ports = set(map(int, ports_in_use))
return min(valid_ports - used_ports)
def set_listen_port(client: BaseWebClient, curr_port: int, new_port: int) -> None:
"""
Set the port the client should listen on in the NGINX config
:param curr_port: The current port the client listens on
:param new_port: The new port to set
:param client: The client to set the port for
:return: None
"""
config = NGINX_SITES_AVAILABLE.joinpath(client.name)
with open(config, "r") as f:
lines = f.readlines()
for i, line in enumerate(lines):
if "listen" in line:
lines[i] = line.replace(str(curr_port), str(new_port))
with open(config, "w") as f:
f.writelines(lines)