From a797dd0b50da545b5c2e34f80b947a80d8564ad0 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Fri, 16 Jun 2023 17:55:44 -0400 Subject: [PATCH] update_manager: validate web type installations Require that "web" installations provide release info to validate existing installations. For known web clients provide a fallback that uses the manifest to validate the installation. Signed-off-by: Eric Callahan --- .../update_manager/update_manager.py | 170 +++++++++++++++--- 1 file changed, 141 insertions(+), 29 deletions(-) diff --git a/moonraker/components/update_manager/update_manager.py b/moonraker/components/update_manager/update_manager.py index 0c0924a..5f6f3d8 100644 --- a/moonraker/components/update_manager/update_manager.py +++ b/moonraker/components/update_manager/update_manager.py @@ -14,6 +14,8 @@ import zipfile import time import tempfile import re +import json +from ...utils import source_info from ...thirdparty.packagekit import enums as PkEnum from . import base_config from .base_deploy import BaseDeploy @@ -1174,6 +1176,7 @@ class PackageKitTransaction: self._dl_remaining = bytes_remaining self._notify_progress() + class WebClientDeploy(BaseDeploy): def __init__(self, config: ConfigHelper, @@ -1181,10 +1184,8 @@ class WebClientDeploy(BaseDeploy): ) -> None: super().__init__(config, cmd_helper, prefix="Web Client") self.repo = config.get('repo').strip().strip("/") - self.owner = self.repo.split("/", 1)[0] + self.owner, self.project_name = self.repo.split("/", 1) self.path = pathlib.Path(config.get("path")).expanduser().resolve() - fm: FileManager = self.server.lookup_component("file_manager") - fm.add_reserved_path(f"update_manager {self.name}", self.path) self.type = config.get('type') def_channel = "stable" if self.type == "web_beta": @@ -1202,6 +1203,8 @@ class WebClientDeploy(BaseDeploy): f"Must be one of the following: stable, beta") self.info_tags: List[str] = config.getlist("info_tags", []) self.persistent_files: List[str] = [] + self.warnings: List[str] = [] + self.version: str = "?" pfiles = config.getlist('persistent_files', None) if pfiles is not None: self.persistent_files = [pf.strip("/") for pf in pfiles] @@ -1209,38 +1212,150 @@ class WebClientDeploy(BaseDeploy): raise config.error( "Invalid value for option 'persistent_files': " "'.version' can not be persistent") + self._valid: bool = True + self._is_prerelease: bool = False + self._is_fallback: bool = False + self._path_writable: bool = False + + async def _validate_client_info(self) -> None: + self._valid = False + self._is_fallback = False + eventloop = self.server.get_event_loop() + self.warnings.clear() + if not self._path_writable: + self.warnings.append( + f"Location at option 'path: {self.path}' is not writable." + ) + elif not self.path.is_dir(): + self.warnings.append( + f"Location at option 'path: {self.path}' does not exist." + ) + elif source_info.within_git_repo(self.path): + self.warnings.append( + f"Location at option 'path: {self.path}' is a git repo." + ) + else: + rinfo = self.path.joinpath("release_info.json") + if rinfo.is_file(): + try: + data = await eventloop.run_in_thread(rinfo.read_text) + uinfo: Dict[str, str] = json.loads(data) + project_name = uinfo["project_name"] + owner = uinfo["project_owner"] + self.version = uinfo["version"] + except Exception: + logging.exception("Failed to load release_info.json.") + else: + self._valid = True + detected_repo = f"{owner}/{project_name}" + if self.repo.lower() != detected_repo.lower(): + self.warnings.append( + f"Value at option 'repo: {self.repo}' does not match " + f"detected repo '{detected_repo}', falling back to " + "detected version." + ) + self.repo = detected_repo + self.owner = owner + self.project_name = project_name + else: + version_path = self.path.joinpath(".version") + if version_path.is_file(): + version = await eventloop.run_in_thread(version_path.read_text) + self.version = version.strip() + self._valid = await self._detect_fallback() + if not self._valid: + self.warnings.append("Failed to validate client installation") + if self.server.is_debug_enabled(): + self.log_info("Debug Enabled, overriding validity checks") + + async def _detect_fallback(self) -> bool: + fallback_defs = { + "mainsail": "mainsail-crew", + "fluidd": "fluidd-core" + } + for fname in ("manifest.json", "manifest.webmanifest"): + manifest = self.path.joinpath(fname) + eventloop = self.server.get_event_loop() + if manifest.is_file(): + try: + mtext = await eventloop.run_in_thread(manifest.read_text) + mdata: Dict[str, Any] = json.loads(mtext) + proj_name: str = mdata["name"].lower() + except Exception: + self.log_exc(f"Failed to load json from {manifest}") + continue + if proj_name in fallback_defs: + owner = fallback_defs[proj_name] + detected_repo = f"{owner}/{proj_name}" + if detected_repo != self.repo.lower(): + self.warnings.append( + f"Value at option 'repo: {self.repo}' does not match " + f"detected repo '{detected_repo}', falling back to " + "detected version." + ) + self.repo = detected_repo + self.owner = owner + self.project_name = proj_name + self._is_fallback = True + return True + return False async def initialize(self) -> Dict[str, Any]: + fm: FileManager = self.server.lookup_component("file_manager") + self._path_writable = not fm.check_reserved_path( + self.path, need_write=True, raise_error=False + ) + if self._path_writable: + fm.add_reserved_path(f"update_manager {self.name}", self.path) + await self._validate_client_info() storage = await super().initialize() - self.version: str = storage.get('version', "?") + if self.version == "?": + self.version = storage.get("version", "?") self.remote_version: str = storage.get('remote_version', "?") self.last_error: str = storage.get('last_error', "") dl_info: List[Any] = storage.get('dl_info', ["?", "?", 0]) self.dl_info: Tuple[str, str, int] = cast( Tuple[str, str, int], tuple(dl_info)) - logging.info(f"\nInitializing Client Updater: '{self.name}'," - f"\nChannel: {self.channel}" - f"\npath: {self.path}") + if not self.needs_refresh(): + self._log_client_info() return storage - async def _get_local_version(self) -> None: - version_path = self.path.joinpath(".version") - if version_path.is_file(): - event_loop = self.server.get_event_loop() - version = await event_loop.run_in_thread(version_path.read_text) - self.version = version.strip() - else: - self.version = "?" + def _log_client_info(self) -> None: + warn_str = "" + if self.warnings: + warn_str = "\nWarnings:\n" + warn_str += "\n".join([f" {item}" for item in self.warnings]) + dl_url, content_type, size = self.dl_info + logging.info( + f"Web Client {self.name} Detected:\n" + f"Repo: {self.repo}\n" + f"Channel: {self.channel}\n" + f"Path: {self.path}\n" + f"Local Version: {self.version}\n" + f"Remote Version: {self.remote_version}\n" + f"Valid: {self._valid}\n" + f"Fallback Client Detected: {self._is_fallback}\n" + f"Pre-release: {self._is_prerelease}\n" + f"Download Url: {dl_url}\n" + f"Download Size: {size}\n" + f"Content Type: {content_type}" + f"{warn_str}" + ) async def refresh(self) -> None: try: - await self._get_local_version() + if not self._valid: + await self._validate_client_info() await self._get_remote_version() except Exception: logging.exception("Error Refreshing Client") + self._log_client_info() self._save_state() async def _get_remote_version(self) -> None: + if not self._valid: + self.log_info("Invalid Web Installation, aborting remote refresh") + return # Remote state if self.channel == "stable": resource = f"repos/{self.repo}/releases/latest" @@ -1279,14 +1394,7 @@ class WebClientDeploy(BaseDeploy): content_type: str = release_asset.get('content_type', "?") size: int = release_asset.get('size', 0) self.dl_info = (dl_url, content_type, size) - logging.info( - f"Github client Info Received:\nRepo: {self.name}\n" - f"Local Version: {self.version}\n" - f"Remote Version: {self.remote_version}\n" - f"Pre-release: {result.get('prerelease', '?')}\n" - f"url: {dl_url}\n" - f"size: {size}\n" - f"Content Type: {content_type}") + self._is_prerelease = result.get('prerelease', False) def get_persistent_data(self) -> Dict[str, Any]: storage = super().get_persistent_data() @@ -1297,6 +1405,10 @@ class WebClientDeploy(BaseDeploy): return storage async def update(self) -> bool: + if not self._valid: + raise self.server.error( + f"Web Client {self.name}: Invalid install detected, aborting update" + ) if self.remote_version == "?": await self._get_remote_version() if self.remote_version == "?": @@ -1331,12 +1443,10 @@ class WebClientDeploy(BaseDeploy): finally: await event_loop.run_in_thread(td.cleanup) self.version = self.remote_version - version_path = self.path.joinpath(".version") - if not version_path.exists(): - await event_loop.run_in_thread( - version_path.write_text, self.version) + await self._validate_client_info() self.cmd_helper.notify_update_response( f"Client Update Finished: {self.name}", is_complete=True) + self._log_client_info() self._save_state() return True @@ -1374,7 +1484,9 @@ class WebClientDeploy(BaseDeploy): 'configured_type': self.type, 'channel': self.channel, 'info_tags': self.info_tags, - 'last_error': self.last_error + 'last_error': self.last_error, + 'is_valid': self._valid, + 'warnings': self.warnings } def load_component(config: ConfigHelper) -> UpdateManager: