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 socket
import ipaddress
import time
import shutil
import distro
import tempfile
import getpass
from confighelper import FileSourceWrapper
from utils import MOONRAKER_PATH
# Annotation imports
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Optional,
Tuple
Tuple,
Union,
cast
)
if TYPE_CHECKING:
from confighelper import ConfigHelper
from websockets import WebRequest
from app import MoonrakerApp
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 .dbus_manager import DbusManager
from dbus_next.aio import ProxyInterface
from dbus_next import Variant
SudoReturn = Union[Awaitable[Tuple[str, bool]], Tuple[str, bool]]
SudoCallback = Callable[[], SudoReturn]
ALLOWED_SERVICES = [
"moonraker", "klipper", "webcamd", "MoonCord",
@ -96,6 +112,8 @@ class Machine:
raise config.error(f"Invalid Provider: {self.provider_type}")
self.sys_provider: BaseProvider = pclass(config)
logging.info(f"Using System Provider: {self.provider_type}")
self.validator = InstallValidator(config)
self.sudo_requests: List[Tuple[SudoCallback, str]] = []
self.server.register_endpoint(
"/machine/reboot", ['POST'], self._handle_machine_request)
@ -114,12 +132,13 @@ class Machine:
"/machine/system_info", ['GET'],
self._handle_sysinfo_request)
self.server.register_endpoint(
"/machine/sudo", ["GET"], self._handle_sudo_check)
"/machine/sudo/info", ["GET"], self._handle_sudo_info)
self.server.register_endpoint(
"/machine/sudo/password", ["POST"],
self._set_sudo_password)
self.server.register_notification("machine:service_state_changed")
self.server.register_notification("machine:sudo_alert")
# Register remote methods
self.server.register_remote_method(
@ -152,6 +171,12 @@ class Machine:
def public_ip(self) -> str:
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):
return self.sys_provider
@ -189,6 +214,12 @@ class Machine:
self.log_service_info(svc_info)
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:
ep = web_request.get_endpoint()
if self.inside_container:
@ -210,15 +241,22 @@ class Machine:
) -> None:
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:
name: str = web_request.get('service')
action = web_request.get_endpoint().split('/')[-1]
if name == "moonraker":
if name == self.unit_name:
if action != "restart":
raise self.server.error(
f"Service action '{action}' not available for moonraker")
event_loop = self.server.get_event_loop()
event_loop.register_callback(self.do_service_action, action, name)
self.restart_moonraker_service()
elif self.sys_provider.is_service_available(name):
await self.do_service_action(action, name)
else:
@ -231,21 +269,73 @@ class Machine:
async def _handle_sysinfo_request(self,
web_request: WebRequest
) -> 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")
if not await self.check_sudo_access():
self._sudo_password = None
raise self.server.error("Invalid password, sudo access was denied")
self.server.send_event("machine:sudo_password_set")
return "ok"
sudo_responses = ["Sudo password successfully set."]
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
) -> Dict[str, Any]:
has_sudo = await self.check_sudo_access()
return {"sudo_access": has_sudo}
check_access = web_request.get("check_access", False)
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]:
return self.system_info
@ -254,6 +344,30 @@ class Machine:
def sudo_password(self) -> Optional[str]:
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:
if not cmds:
cmds = ["systemctl --version", "ls /lost+found"]
@ -1017,5 +1131,464 @@ class SystemdDbusProvider(BaseProvider):
processed[key] = val
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:
return Machine(config)