machine: implement install validation
Signed-off-by: Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
parent
7004722499
commit
6e56815b42
|
@ -15,27 +15,43 @@ import asyncio
|
||||||
import platform
|
import platform
|
||||||
import socket
|
import socket
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
import distro
|
import distro
|
||||||
|
import tempfile
|
||||||
|
import getpass
|
||||||
|
from confighelper import FileSourceWrapper
|
||||||
|
from utils import MOONRAKER_PATH
|
||||||
|
|
||||||
# Annotation imports
|
# Annotation imports
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
|
Awaitable,
|
||||||
Callable,
|
Callable,
|
||||||
Dict,
|
Dict,
|
||||||
List,
|
List,
|
||||||
Optional,
|
Optional,
|
||||||
Tuple
|
Tuple,
|
||||||
|
Union,
|
||||||
|
cast
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from confighelper import ConfigHelper
|
from confighelper import ConfigHelper
|
||||||
from websockets import WebRequest
|
from websockets import WebRequest
|
||||||
|
from app import MoonrakerApp
|
||||||
from .shell_command import ShellCommandFactory as SCMDComp
|
from .shell_command import ShellCommandFactory as SCMDComp
|
||||||
|
from .database import MoonrakerDatabase
|
||||||
|
from .file_manager.file_manager import FileManager
|
||||||
|
from .authorization import Authorization
|
||||||
|
from .announcements import Announcements
|
||||||
from .proc_stats import ProcStats
|
from .proc_stats import ProcStats
|
||||||
from .dbus_manager import DbusManager
|
from .dbus_manager import DbusManager
|
||||||
from dbus_next.aio import ProxyInterface
|
from dbus_next.aio import ProxyInterface
|
||||||
from dbus_next import Variant
|
from dbus_next import Variant
|
||||||
|
SudoReturn = Union[Awaitable[Tuple[str, bool]], Tuple[str, bool]]
|
||||||
|
SudoCallback = Callable[[], SudoReturn]
|
||||||
|
|
||||||
ALLOWED_SERVICES = [
|
ALLOWED_SERVICES = [
|
||||||
"moonraker", "klipper", "webcamd", "MoonCord",
|
"moonraker", "klipper", "webcamd", "MoonCord",
|
||||||
|
@ -96,6 +112,8 @@ class Machine:
|
||||||
raise config.error(f"Invalid Provider: {self.provider_type}")
|
raise config.error(f"Invalid Provider: {self.provider_type}")
|
||||||
self.sys_provider: BaseProvider = pclass(config)
|
self.sys_provider: BaseProvider = pclass(config)
|
||||||
logging.info(f"Using System Provider: {self.provider_type}")
|
logging.info(f"Using System Provider: {self.provider_type}")
|
||||||
|
self.validator = InstallValidator(config)
|
||||||
|
self.sudo_requests: List[Tuple[SudoCallback, str]] = []
|
||||||
|
|
||||||
self.server.register_endpoint(
|
self.server.register_endpoint(
|
||||||
"/machine/reboot", ['POST'], self._handle_machine_request)
|
"/machine/reboot", ['POST'], self._handle_machine_request)
|
||||||
|
@ -114,12 +132,13 @@ class Machine:
|
||||||
"/machine/system_info", ['GET'],
|
"/machine/system_info", ['GET'],
|
||||||
self._handle_sysinfo_request)
|
self._handle_sysinfo_request)
|
||||||
self.server.register_endpoint(
|
self.server.register_endpoint(
|
||||||
"/machine/sudo", ["GET"], self._handle_sudo_check)
|
"/machine/sudo/info", ["GET"], self._handle_sudo_info)
|
||||||
self.server.register_endpoint(
|
self.server.register_endpoint(
|
||||||
"/machine/sudo/password", ["POST"],
|
"/machine/sudo/password", ["POST"],
|
||||||
self._set_sudo_password)
|
self._set_sudo_password)
|
||||||
|
|
||||||
self.server.register_notification("machine:service_state_changed")
|
self.server.register_notification("machine:service_state_changed")
|
||||||
|
self.server.register_notification("machine:sudo_alert")
|
||||||
|
|
||||||
# Register remote methods
|
# Register remote methods
|
||||||
self.server.register_remote_method(
|
self.server.register_remote_method(
|
||||||
|
@ -152,6 +171,12 @@ class Machine:
|
||||||
def public_ip(self) -> str:
|
def public_ip(self) -> str:
|
||||||
return self._public_ip
|
return self._public_ip
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_name(self) -> str:
|
||||||
|
svc_info = self.moonraker_service_info
|
||||||
|
unit_name = svc_info.get("unit_name", "moonraker.service")
|
||||||
|
return unit_name.split(".", 1)[0]
|
||||||
|
|
||||||
def get_system_provider(self):
|
def get_system_provider(self):
|
||||||
return self.sys_provider
|
return self.sys_provider
|
||||||
|
|
||||||
|
@ -189,6 +214,12 @@ class Machine:
|
||||||
self.log_service_info(svc_info)
|
self.log_service_info(svc_info)
|
||||||
self.init_evt.set()
|
self.init_evt.set()
|
||||||
|
|
||||||
|
async def validate_installation(self) -> bool:
|
||||||
|
return await self.validator.perform_validation()
|
||||||
|
|
||||||
|
async def on_exit(self) -> None:
|
||||||
|
await self.validator.remove_announcement()
|
||||||
|
|
||||||
async def _handle_machine_request(self, web_request: WebRequest) -> str:
|
async def _handle_machine_request(self, web_request: WebRequest) -> str:
|
||||||
ep = web_request.get_endpoint()
|
ep = web_request.get_endpoint()
|
||||||
if self.inside_container:
|
if self.inside_container:
|
||||||
|
@ -210,15 +241,22 @@ class Machine:
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.sys_provider.do_service_action(action, service_name)
|
await self.sys_provider.do_service_action(action, service_name)
|
||||||
|
|
||||||
|
def restart_moonraker_service(self):
|
||||||
|
async def wrapper():
|
||||||
|
try:
|
||||||
|
await self.do_service_action("restart", self.unit_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.server.get_event_loop().create_task(wrapper())
|
||||||
|
|
||||||
async def _handle_service_request(self, web_request: WebRequest) -> str:
|
async def _handle_service_request(self, web_request: WebRequest) -> str:
|
||||||
name: str = web_request.get('service')
|
name: str = web_request.get('service')
|
||||||
action = web_request.get_endpoint().split('/')[-1]
|
action = web_request.get_endpoint().split('/')[-1]
|
||||||
if name == "moonraker":
|
if name == self.unit_name:
|
||||||
if action != "restart":
|
if action != "restart":
|
||||||
raise self.server.error(
|
raise self.server.error(
|
||||||
f"Service action '{action}' not available for moonraker")
|
f"Service action '{action}' not available for moonraker")
|
||||||
event_loop = self.server.get_event_loop()
|
self.restart_moonraker_service()
|
||||||
event_loop.register_callback(self.do_service_action, action, name)
|
|
||||||
elif self.sys_provider.is_service_available(name):
|
elif self.sys_provider.is_service_available(name):
|
||||||
await self.do_service_action(action, name)
|
await self.do_service_action(action, name)
|
||||||
else:
|
else:
|
||||||
|
@ -231,21 +269,73 @@ class Machine:
|
||||||
async def _handle_sysinfo_request(self,
|
async def _handle_sysinfo_request(self,
|
||||||
web_request: WebRequest
|
web_request: WebRequest
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
return {'system_info': self.system_info}
|
return {"system_info": self.system_info}
|
||||||
|
|
||||||
async def _set_sudo_password(self, web_request: WebRequest) -> str:
|
async def _set_sudo_password(
|
||||||
|
self, web_request: WebRequest
|
||||||
|
) -> Dict[str, Any]:
|
||||||
self._sudo_password = web_request.get_str("password")
|
self._sudo_password = web_request.get_str("password")
|
||||||
if not await self.check_sudo_access():
|
if not await self.check_sudo_access():
|
||||||
self._sudo_password = None
|
self._sudo_password = None
|
||||||
raise self.server.error("Invalid password, sudo access was denied")
|
raise self.server.error("Invalid password, sudo access was denied")
|
||||||
self.server.send_event("machine:sudo_password_set")
|
sudo_responses = ["Sudo password successfully set."]
|
||||||
return "ok"
|
restart: bool = False
|
||||||
|
failed: List[Tuple[SudoCallback, str]] = []
|
||||||
|
failed_msgs: List[str] = []
|
||||||
|
if self.sudo_requests:
|
||||||
|
while self.sudo_requests:
|
||||||
|
cb, msg = self.sudo_requests.pop(0)
|
||||||
|
try:
|
||||||
|
ret = cb()
|
||||||
|
if isinstance(ret, Awaitable):
|
||||||
|
ret = await ret
|
||||||
|
msg, need_restart = ret
|
||||||
|
sudo_responses.append(msg)
|
||||||
|
restart |= need_restart
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
failed.append((cb, msg))
|
||||||
|
failed_msgs.append(str(e))
|
||||||
|
restart = False if len(failed) > 0 else restart
|
||||||
|
self.sudo_requests = failed
|
||||||
|
if not restart and len(sudo_responses) > 1:
|
||||||
|
# at least one successful response and not restarting
|
||||||
|
eventloop = self.server.get_event_loop()
|
||||||
|
eventloop.delay_callback(
|
||||||
|
.05, self.server.send_event,
|
||||||
|
"machine:sudo_alert",
|
||||||
|
{
|
||||||
|
"sudo_requested": self.sudo_requested,
|
||||||
|
"request_messages": self.sudo_request_messages
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if failed_msgs:
|
||||||
|
err_msg = "\n".join(failed_msgs)
|
||||||
|
raise self.server.error(err_msg, 500)
|
||||||
|
if restart:
|
||||||
|
self.restart_moonraker_service()
|
||||||
|
sudo_responses.append(
|
||||||
|
"Moonraker is currently in the process of restarting."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"sudo_responses": sudo_responses,
|
||||||
|
"is_restarting": restart
|
||||||
|
}
|
||||||
|
|
||||||
async def _handle_sudo_check(
|
async def _handle_sudo_info(
|
||||||
self, web_request: WebRequest
|
self, web_request: WebRequest
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
has_sudo = await self.check_sudo_access()
|
check_access = web_request.get("check_access", False)
|
||||||
return {"sudo_access": has_sudo}
|
has_sudo: Optional[bool] = None
|
||||||
|
if check_access:
|
||||||
|
has_sudo = await self.check_sudo_access()
|
||||||
|
return {
|
||||||
|
"sudo_access": has_sudo,
|
||||||
|
"linux_user": self.linux_user,
|
||||||
|
"sudo_requested": self.sudo_requested,
|
||||||
|
"request_messages": self.sudo_request_messages
|
||||||
|
}
|
||||||
|
|
||||||
def get_system_info(self) -> Dict[str, Any]:
|
def get_system_info(self) -> Dict[str, Any]:
|
||||||
return self.system_info
|
return self.system_info
|
||||||
|
@ -254,6 +344,30 @@ class Machine:
|
||||||
def sudo_password(self) -> Optional[str]:
|
def sudo_password(self) -> Optional[str]:
|
||||||
return self._sudo_password
|
return self._sudo_password
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sudo_requested(self) -> bool:
|
||||||
|
return len(self.sudo_requests) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def linux_user(self) -> str:
|
||||||
|
return getpass.getuser()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sudo_request_messages(self) -> List[str]:
|
||||||
|
return [req[1] for req in self.sudo_requests]
|
||||||
|
|
||||||
|
def register_sudo_request(
|
||||||
|
self, callback: SudoCallback, message: str
|
||||||
|
) -> None:
|
||||||
|
self.sudo_requests.append((callback, message))
|
||||||
|
self.server.send_event(
|
||||||
|
"machine:sudo_alert",
|
||||||
|
{
|
||||||
|
"sudo_requested": True,
|
||||||
|
"request_messages": self.sudo_request_messages
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def check_sudo_access(self, cmds: List[str] = []) -> bool:
|
async def check_sudo_access(self, cmds: List[str] = []) -> bool:
|
||||||
if not cmds:
|
if not cmds:
|
||||||
cmds = ["systemctl --version", "ls /lost+found"]
|
cmds = ["systemctl --version", "ls /lost+found"]
|
||||||
|
@ -1017,5 +1131,464 @@ class SystemdDbusProvider(BaseProvider):
|
||||||
processed[key] = val
|
processed[key] = val
|
||||||
return processed
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
# Install validation
|
||||||
|
INSTALL_VERSION = 1
|
||||||
|
SERVICE_VERSION = 1
|
||||||
|
|
||||||
|
SYSTEMD_UNIT = \
|
||||||
|
"""
|
||||||
|
# systemd service file for moonraker
|
||||||
|
[Unit]
|
||||||
|
Description=API Server for Klipper SV%d
|
||||||
|
Requires=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=%s
|
||||||
|
SupplementaryGroups=moonraker-admin
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory=%s
|
||||||
|
EnvironmentFile=%s
|
||||||
|
ExecStart=%s $MOONRAKER_ARGS
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
""" # noqa: E122
|
||||||
|
|
||||||
|
ENVIRONMENT = "MOONRAKER_ARGS=\"%s/moonraker/moonraker.py %s\""
|
||||||
|
TEMPLATE_NAME = "password_request.html"
|
||||||
|
|
||||||
|
class ValidationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class InstallValidator:
|
||||||
|
def __init__(self, config: ConfigHelper) -> None:
|
||||||
|
self.server = config.get_server()
|
||||||
|
self.config = config
|
||||||
|
self.server.load_component(config, "template")
|
||||||
|
self.force_validation = config.getboolean("force_validation", False)
|
||||||
|
self.sc_enabled = config.getboolean("validate_service", True)
|
||||||
|
self.cc_enabled = config.getboolean("validate_config", True)
|
||||||
|
app_args = self.server.get_app_args()
|
||||||
|
self.data_path = pathlib.Path(app_args["data_path"])
|
||||||
|
self._update_backup_path()
|
||||||
|
self.alias = app_args["alias"]
|
||||||
|
self.data_path_valid = True
|
||||||
|
self._sudo_requested = False
|
||||||
|
self.announcement_id = ""
|
||||||
|
|
||||||
|
def _update_backup_path(self):
|
||||||
|
str_time = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
|
||||||
|
if not hasattr(self, "backup_path"):
|
||||||
|
self.backup_path = self.data_path.joinpath(f"backup/{str_time}")
|
||||||
|
elif not self.backup_path.exists():
|
||||||
|
self.backup_path = self.data_path.joinpath(f"backup/{str_time}")
|
||||||
|
|
||||||
|
async def perform_validation(self) -> bool:
|
||||||
|
db: MoonrakerDatabase = self.server.lookup_component("database")
|
||||||
|
install_ver: int = await db.get_item(
|
||||||
|
"moonraker", "validate_install.install_version", 0
|
||||||
|
)
|
||||||
|
if INSTALL_VERSION <= install_ver and not self.force_validation:
|
||||||
|
logging.debug("Installation version in database up to date")
|
||||||
|
return False
|
||||||
|
need_restart: bool = False
|
||||||
|
has_error: bool = False
|
||||||
|
try:
|
||||||
|
name = "service"
|
||||||
|
need_restart = await self._check_service_file()
|
||||||
|
name = "config"
|
||||||
|
need_restart |= await self._check_configuration()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except ValidationError as ve:
|
||||||
|
has_error = True
|
||||||
|
self.server.add_warning(str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
has_error = True
|
||||||
|
msg = f"Failed to validate {name}: {e}"
|
||||||
|
logging.exception(msg)
|
||||||
|
self.server.add_warning(msg, log=False)
|
||||||
|
else:
|
||||||
|
await db.insert_item(
|
||||||
|
"moonraker", "validate_install.install_version", INSTALL_VERSION
|
||||||
|
)
|
||||||
|
if not has_error and need_restart:
|
||||||
|
machine: Machine = self.server.lookup_component("machine")
|
||||||
|
machine.restart_moonraker_service()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _check_service_file(self) -> bool:
|
||||||
|
if not self.sc_enabled:
|
||||||
|
return False
|
||||||
|
machine: Machine = self.server.lookup_component("machine")
|
||||||
|
if machine.is_inside_container():
|
||||||
|
raise ValidationError(
|
||||||
|
"Moonraker instance running inside a container, "
|
||||||
|
"cannot validate service file."
|
||||||
|
)
|
||||||
|
if machine.get_provider_type() == "none":
|
||||||
|
raise ValidationError(
|
||||||
|
"No machine provider configured, cannot validate service file."
|
||||||
|
)
|
||||||
|
logging.info("Performing Service Validation...")
|
||||||
|
app_args = self.server.get_app_args()
|
||||||
|
svc_info = machine.get_moonraker_service_info()
|
||||||
|
if not svc_info:
|
||||||
|
raise ValidationError(
|
||||||
|
"Unable to retrieve Moonraker service info. Service file "
|
||||||
|
"must be updated manually."
|
||||||
|
)
|
||||||
|
props: Dict[str, str] = svc_info.get("properties", {})
|
||||||
|
if "FragmentPath" not in props:
|
||||||
|
raise ValidationError(
|
||||||
|
"Unable to locate path to Moonraker's service unit. Service "
|
||||||
|
"file must be must be updated manually."
|
||||||
|
)
|
||||||
|
desc = props.get("Description", "")
|
||||||
|
ver_match = re.match(r"API Server for Klipper SV(\d+)", desc)
|
||||||
|
if ver_match is not None and int(ver_match.group(1)) == SERVICE_VERSION:
|
||||||
|
logging.info("Service file validated and up to date")
|
||||||
|
return False
|
||||||
|
unit: str = svc_info.get("unit_name", "").split(".", 1)[0]
|
||||||
|
if not unit:
|
||||||
|
raise ValidationError(
|
||||||
|
"Unable to retrieve service unit name. Service file "
|
||||||
|
"must be updated manually."
|
||||||
|
)
|
||||||
|
if unit != self.alias and self.alias == "moonraker":
|
||||||
|
# alias differs from unit name, current alias is set to default
|
||||||
|
self.alias = unit
|
||||||
|
if app_args["is_default_data_path"]:
|
||||||
|
# Using default datapath, switch to alias based path to
|
||||||
|
# avoid conflict
|
||||||
|
new_dp = pathlib.Path(f"~/{unit}_data").expanduser().resolve()
|
||||||
|
if new_dp.exists() and not self._check_path_bare(new_dp):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Cannot resolve data path for custom unit '{unit}', "
|
||||||
|
f"data path '{new_dp}' already exists. Service file "
|
||||||
|
"must be updated manually."
|
||||||
|
)
|
||||||
|
# If the current path is bare we can remove it
|
||||||
|
if self._check_path_bare(self.data_path):
|
||||||
|
shutil.rmtree(self.data_path)
|
||||||
|
self.data_path = new_dp
|
||||||
|
if not self.data_path.exists():
|
||||||
|
self.data_path.mkdir()
|
||||||
|
# A non-default datapath requires successful update of the
|
||||||
|
# service
|
||||||
|
self.data_path_valid = False
|
||||||
|
if await machine.check_sudo_access():
|
||||||
|
logging.info("Moonraker has sudo access")
|
||||||
|
else:
|
||||||
|
self._request_sudo_access()
|
||||||
|
raise ValidationError(
|
||||||
|
"Moonraker requires sudo permission to update the system "
|
||||||
|
"service. Please check your notifications for further "
|
||||||
|
"intructions."
|
||||||
|
)
|
||||||
|
self._sudo_requested = False
|
||||||
|
svc_dest = pathlib.Path(props["FragmentPath"])
|
||||||
|
user: str = props["User"]
|
||||||
|
tmp_svc = pathlib.Path(
|
||||||
|
tempfile.gettempdir()
|
||||||
|
).joinpath(f"{unit}-tmp.svc")
|
||||||
|
src_path = pathlib.Path(MOONRAKER_PATH)
|
||||||
|
# Create local environment file
|
||||||
|
env_file = src_path.joinpath(f"{unit}.env")
|
||||||
|
cmd_args = f"-a {unit} -d {self.data_path}"
|
||||||
|
cfg_file = pathlib.Path(app_args["config_file"])
|
||||||
|
fm: FileManager = self.server.lookup_component("file_manager")
|
||||||
|
cfg_path = fm.get_directory("config")
|
||||||
|
log_path = fm.get_directory("logs")
|
||||||
|
if not cfg_path or not cfg_file.parent.samefile(cfg_path):
|
||||||
|
# Configuration file does not exist in config path
|
||||||
|
cmd_args += f" -c {cfg_file}"
|
||||||
|
elif cfg_file.name != "moonraker.conf":
|
||||||
|
cfg_file = self.data_path.joinpath(f"config/{cfg_file.name}")
|
||||||
|
cmd_args += f" -c {cfg_file}"
|
||||||
|
if not app_args["log_file"]:
|
||||||
|
# No log file configured
|
||||||
|
cmd_args += f" -n"
|
||||||
|
else:
|
||||||
|
# Log file does not exist in log path
|
||||||
|
log_file = pathlib.Path(app_args["log_file"])
|
||||||
|
if not log_path or not log_file.parent.samefile(log_path):
|
||||||
|
cmd_args += f" -l {log_file}"
|
||||||
|
elif log_file.name != "moonraker.log":
|
||||||
|
cfg_file = self.data_path.joinpath(f"logs/{log_file.name}")
|
||||||
|
cmd_args += f" -l {log_file}"
|
||||||
|
# backup existing service files
|
||||||
|
self._update_backup_path()
|
||||||
|
svc_bkp_path = self.backup_path.joinpath("service")
|
||||||
|
os.makedirs(str(svc_bkp_path), exist_ok=True)
|
||||||
|
if env_file.exists():
|
||||||
|
env_bkp = svc_bkp_path.joinpath(env_file.name)
|
||||||
|
shutil.copy2(str(env_file), str(env_bkp))
|
||||||
|
service_bkp = svc_bkp_path.joinpath(svc_dest.name)
|
||||||
|
shutil.copy2(str(svc_dest), str(service_bkp))
|
||||||
|
# write temporary service file
|
||||||
|
tmp_svc.write_text(
|
||||||
|
SYSTEMD_UNIT
|
||||||
|
% (SERVICE_VERSION, user, src_path, env_file, sys.executable)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await machine.exec_sudo_command(f"cp -f {tmp_svc} {svc_dest}")
|
||||||
|
await machine.exec_sudo_command(f"systemctl daemon-reload")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to update moonraker service unit")
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to update service unit file '{svc_dest}'. Update must "
|
||||||
|
f"be performed manually."
|
||||||
|
) from None
|
||||||
|
finally:
|
||||||
|
tmp_svc.unlink()
|
||||||
|
# write new environment
|
||||||
|
env_file.write_text(ENVIRONMENT % (src_path, cmd_args))
|
||||||
|
self.data_path_valid = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _check_path_bare(self, path: pathlib.Path) -> bool:
|
||||||
|
empty: bool = True
|
||||||
|
for item in path.iterdir():
|
||||||
|
if (
|
||||||
|
item.is_file() or
|
||||||
|
item.is_symlink() or
|
||||||
|
item.name not in ["gcodes", "config", "logs", "certs"]
|
||||||
|
):
|
||||||
|
empty = False
|
||||||
|
break
|
||||||
|
if next(item.iterdir(), None) is not None:
|
||||||
|
empty = False
|
||||||
|
break
|
||||||
|
return empty
|
||||||
|
|
||||||
|
def _link_data_subfolder(
|
||||||
|
self, folder_name: str, source_dir: Union[str, pathlib.Path]
|
||||||
|
) -> None:
|
||||||
|
if isinstance(source_dir, str):
|
||||||
|
source_dir = pathlib.Path(source_dir).expanduser().resolve()
|
||||||
|
if not source_dir.is_dir():
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to link subfolder '{folder_name}' to source path "
|
||||||
|
f"'{source_dir}'. The requusted path is not a valid directory."
|
||||||
|
)
|
||||||
|
subfolder = self.data_path.joinpath(folder_name)
|
||||||
|
if subfolder.is_symlink():
|
||||||
|
if not subfolder.samefile(source_dir):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to link subfolder '{folder_name}' to "
|
||||||
|
f"'{source_dir}'. '{folder_name}' already exists and is "
|
||||||
|
f"linked to {subfolder}. This conflict requires "
|
||||||
|
"manual resolution."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not subfolder.exists():
|
||||||
|
subfolder.symlink_to(source_dir)
|
||||||
|
return
|
||||||
|
if subfolder.is_dir() and next(subfolder.iterdir(), None) is None:
|
||||||
|
subfolder.rmdir()
|
||||||
|
subfolder.symlink_to(source_dir)
|
||||||
|
return
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to link subfolder '{folder_name}' to '{source_dir}'. "
|
||||||
|
f"Folder '{folder_name}' already exists. This conflict requires "
|
||||||
|
"manual resolution."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _link_data_file(
|
||||||
|
self,
|
||||||
|
data_file: Union[str, pathlib.Path],
|
||||||
|
target: Union[str, pathlib.Path]
|
||||||
|
) -> None:
|
||||||
|
if isinstance(data_file, str):
|
||||||
|
data_file = pathlib.Path(data_file)
|
||||||
|
if isinstance(target, str):
|
||||||
|
target = pathlib.Path(target)
|
||||||
|
target = target.expanduser().resolve()
|
||||||
|
if not target.is_file():
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to link data file {data_file.name}. Target "
|
||||||
|
f"{target} is not a valid file."
|
||||||
|
)
|
||||||
|
if data_file.is_symlink():
|
||||||
|
if not data_file.samefile(target):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to link data file {data_file.name}. Link "
|
||||||
|
f"to {data_file.resolve()} already exists. This conflict "
|
||||||
|
"must be resolved manually."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not data_file.exists():
|
||||||
|
data_file.symlink_to(target)
|
||||||
|
return
|
||||||
|
raise ValidationError(
|
||||||
|
f"Failed to link data file {data_file.name}. File already exists. "
|
||||||
|
f"This conflict must be resolved manually."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_configuration(self) -> bool:
|
||||||
|
if not self.cc_enabled or not self.data_path_valid:
|
||||||
|
return False
|
||||||
|
db: MoonrakerDatabase = self.server.lookup_component("database")
|
||||||
|
cfg_source = cast(FileSourceWrapper, self.config.get_source())
|
||||||
|
cfg_source.backup_source()
|
||||||
|
try:
|
||||||
|
# write current configuration to backup path
|
||||||
|
self._update_backup_path()
|
||||||
|
cfg_bkp_path = self.backup_path.joinpath("config")
|
||||||
|
os.makedirs(str(cfg_bkp_path), exist_ok=True)
|
||||||
|
await cfg_source.write_config(cfg_bkp_path)
|
||||||
|
# Create symbolic links for configured folders
|
||||||
|
server_cfg = self.config["server"]
|
||||||
|
fm_cfg = self.config["file_manager"]
|
||||||
|
db_cfg = self.config["database"]
|
||||||
|
cfg_path = fm_cfg.get("config_path", None)
|
||||||
|
if cfg_path is None:
|
||||||
|
cfg_path = server_cfg.get("config_path", None)
|
||||||
|
if cfg_path is not None:
|
||||||
|
self._link_data_subfolder("config", cfg_path)
|
||||||
|
cfg_source.remove_option("server", "config_path")
|
||||||
|
cfg_source.remove_option("file_manager", "config_path")
|
||||||
|
|
||||||
|
log_path = fm_cfg.get("log_path", None)
|
||||||
|
if log_path is None:
|
||||||
|
log_path = server_cfg.get("log_path", None)
|
||||||
|
if log_path is not None:
|
||||||
|
self._link_data_subfolder("logs", log_path)
|
||||||
|
cfg_source.remove_option("server", "log_path")
|
||||||
|
cfg_source.remove_option("file_manager", "log_path")
|
||||||
|
|
||||||
|
gc_path: Optional[str] = await db.get_item(
|
||||||
|
"moonraker", "file_manager.gcode_path", None
|
||||||
|
)
|
||||||
|
if gc_path is not None:
|
||||||
|
self._link_data_subfolder("gcodes", gc_path)
|
||||||
|
db.delete_item("moonraker", "file_manager.gcode_path")
|
||||||
|
|
||||||
|
db_path = db_cfg.get("database_path", None)
|
||||||
|
default_db = pathlib.Path("~/.moonraker_database").expanduser()
|
||||||
|
if db_path is None and default_db.exists():
|
||||||
|
self._link_data_subfolder("database", default_db)
|
||||||
|
elif db_path is not None:
|
||||||
|
self._link_data_subfolder("database", db_path)
|
||||||
|
cfg_source.remove_option("database", "database_path")
|
||||||
|
|
||||||
|
# Link individual files
|
||||||
|
secrets_path = self.config["secrets"].get("secrets_path", None)
|
||||||
|
if secrets_path is not None:
|
||||||
|
secrets_dest = self.data_path.joinpath(f"{self.alias}.secrets")
|
||||||
|
self._link_data_file(secrets_dest, secrets_path)
|
||||||
|
cfg_source.remove_option("secrets", "secrets_path")
|
||||||
|
certs_path = self.data_path.joinpath("certs")
|
||||||
|
if not certs_path.exists():
|
||||||
|
certs_path.mkdir()
|
||||||
|
ssl_cert = server_cfg.get("ssl_certificate_path", None)
|
||||||
|
if ssl_cert is not None:
|
||||||
|
cert_dest = certs_path.joinpath(f"{self.alias}.cert")
|
||||||
|
self._link_data_file(cert_dest, ssl_cert)
|
||||||
|
cfg_source.remove_option("server", "ssl_certificate_path")
|
||||||
|
ssl_key = server_cfg.get("ssl_key_path", None)
|
||||||
|
if ssl_key is not None:
|
||||||
|
key_dest = certs_path.joinpath(f"{self.alias}.key")
|
||||||
|
self._link_data_file(key_dest, ssl_key)
|
||||||
|
cfg_source.remove_option("server", "ssl_key_path")
|
||||||
|
except Exception:
|
||||||
|
cfg_source.cancel()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.cc_enabled = False
|
||||||
|
return await cfg_source.save()
|
||||||
|
|
||||||
|
def _request_sudo_access(self) -> None:
|
||||||
|
if self._sudo_requested:
|
||||||
|
return
|
||||||
|
self._sudo_requested = True
|
||||||
|
auth: Optional[Authorization]
|
||||||
|
auth = self.server.lookup_component("authorizaton", None)
|
||||||
|
if auth is not None:
|
||||||
|
# Bypass authentication requirements
|
||||||
|
auth.register_permited_path("/machine/sudo/password")
|
||||||
|
machine: Machine = self.server.lookup_component("machine")
|
||||||
|
machine.register_sudo_request(
|
||||||
|
self._on_password_received,
|
||||||
|
"Root access required to update Moonraker's systemd service."
|
||||||
|
)
|
||||||
|
if not machine.public_ip:
|
||||||
|
async def wrapper(pub_ip):
|
||||||
|
if not pub_ip:
|
||||||
|
return
|
||||||
|
await self.remove_announcement()
|
||||||
|
self._announce_sudo_request()
|
||||||
|
self.server.register_event_handler(
|
||||||
|
"machine:public_ip_changed", wrapper
|
||||||
|
)
|
||||||
|
self._announce_sudo_request()
|
||||||
|
|
||||||
|
def _announce_sudo_request(self) -> None:
|
||||||
|
machine: Machine = self.server.lookup_component("machine")
|
||||||
|
host_info = self.server.get_host_info()
|
||||||
|
host_addr: str = host_info["address"]
|
||||||
|
if host_addr.lower() not in ["all", "0.0.0.0", "::"]:
|
||||||
|
address = host_addr
|
||||||
|
else:
|
||||||
|
address = machine.public_ip
|
||||||
|
if not address:
|
||||||
|
address = f"{host_info['hostname']}.local"
|
||||||
|
elif ":" in address:
|
||||||
|
# ipv6 address
|
||||||
|
address = f"[{address}]"
|
||||||
|
app: MoonrakerApp = self.server.lookup_component("application")
|
||||||
|
scheme = "https" if app.https_enabled() else "http"
|
||||||
|
host_info = self.server.get_host_info()
|
||||||
|
port = host_info["port"]
|
||||||
|
url = f"{scheme}://{address}:{port}/"
|
||||||
|
ancmp: Announcements = self.server.lookup_component("announcements")
|
||||||
|
entry = ancmp.add_internal_announcement(
|
||||||
|
"Sudo Password Required",
|
||||||
|
"Moonraker requires sudo access to finish updating. "
|
||||||
|
"Please click on the attached link and follow the "
|
||||||
|
"instructions.",
|
||||||
|
url, "high", "machine"
|
||||||
|
)
|
||||||
|
self.announcement_id = entry.get("entry_id", "")
|
||||||
|
|
||||||
|
async def remove_announcement(self):
|
||||||
|
if not self.announcement_id:
|
||||||
|
return
|
||||||
|
ancmp: Announcements = self.server.lookup_component("announcements")
|
||||||
|
# remove stale announcement
|
||||||
|
try:
|
||||||
|
await ancmp.remove_announcement(self.announcement_id)
|
||||||
|
except self.server.error:
|
||||||
|
pass
|
||||||
|
self.announcement_id = ""
|
||||||
|
|
||||||
|
async def _on_password_received(self) -> Tuple[str, bool]:
|
||||||
|
try:
|
||||||
|
name = "Service"
|
||||||
|
await self._check_service_file()
|
||||||
|
name = "Config"
|
||||||
|
await self._check_configuration()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise self.server.error(
|
||||||
|
f"{name} validation failed with error:\n{e}", 500
|
||||||
|
) from e
|
||||||
|
await self.remove_announcement()
|
||||||
|
db: MoonrakerDatabase = self.server.lookup_component("database")
|
||||||
|
await db.insert_item(
|
||||||
|
"moonraker", "validate_install.install_version", INSTALL_VERSION
|
||||||
|
)
|
||||||
|
return "System update complete.", True
|
||||||
|
|
||||||
def load_component(config: ConfigHelper) -> Machine:
|
def load_component(config: ConfigHelper) -> Machine:
|
||||||
return Machine(config)
|
return Machine(config)
|
||||||
|
|
Loading…
Reference in New Issue