machine: implement install validation

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
Eric Callahan 2022-08-18 10:28:16 -04:00 committed by Eric Callahan
parent 7004722499
commit 6e56815b42
1 changed files with 585 additions and 12 deletions

View File

@ -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]:
check_access = web_request.get("check_access", False)
has_sudo: Optional[bool] = None
if check_access:
has_sudo = await self.check_sudo_access() has_sudo = await self.check_sudo_access()
return {"sudo_access": has_sudo} 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)