update_manager: initial implementation
This manager can perform updates on moonraker and klipper, assuming that the source is located in a valid git repo, the origin points to the official repo, and the currently selected branch is "master". Signed-off-by: Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
parent
83381446a0
commit
d9af161a18
|
@ -0,0 +1,475 @@
|
|||
# Provides updates for Klipper and Moonraker
|
||||
#
|
||||
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
|
||||
#
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license.
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import json
|
||||
import sys
|
||||
import shutil
|
||||
import zipfile
|
||||
import io
|
||||
import tornado.gen
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
|
||||
MOONRAKER_PATH = os.path.normpath(os.path.join(
|
||||
os.path.dirname(__file__), "../.."))
|
||||
|
||||
# TODO: May want to attempt to look up the disto for the correct
|
||||
# klippy install script or have the user configure it
|
||||
APT_CMD = "sudo DEBIAN_FRONTEND=noninteractive apt-get"
|
||||
REPO_PREFIX = "https://api.github.com/repos"
|
||||
REPO_DATA = {
|
||||
'moonraker': {
|
||||
'repo_url': f"{REPO_PREFIX}/arksine/moonraker/branches/master",
|
||||
'origin': "https://github.com/Arksine/moonraker.git",
|
||||
'install_script': "scripts/install-moonraker.sh",
|
||||
'requirements': "scripts/moonraker-requirements.txt",
|
||||
'venv_args': "-p python3 --system-site-packages"
|
||||
},
|
||||
'klipper': {
|
||||
'repo_url': f"{REPO_PREFIX}/kevinoconnor/klipper/branches/master",
|
||||
'origin': "https://github.com/KevinOConnor/klipper.git",
|
||||
'install_script': "scripts/install-octopi.sh",
|
||||
'requirements': "scripts/klippy-requirements.txt",
|
||||
'venv_args': "-p python2"
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateManager:
|
||||
def __init__(self, config):
|
||||
self.server = config.get_server()
|
||||
AsyncHTTPClient.configure(None, defaults=dict(user_agent="Moonraker"))
|
||||
self.http_client = AsyncHTTPClient()
|
||||
sw_version = config['system_args'].get('software_version')
|
||||
self.updaters = {
|
||||
"system": PackageUpdater(self),
|
||||
"moonraker": GitUpdater(self, "moonraker", MOONRAKER_PATH,
|
||||
sw_version, sys.executable)
|
||||
}
|
||||
self.current_update = None
|
||||
client_repo = config.get("client_repo", None)
|
||||
if client_repo is not None:
|
||||
client_path = os.path.expanduser(config.get("client_path"))
|
||||
if os.path.islink(client_path):
|
||||
raise config.error(
|
||||
"Option 'client_path' cannot be set to a symbolic link")
|
||||
self.updaters['client'] = ClientUpdater(
|
||||
self, client_repo, client_path)
|
||||
|
||||
self.server.register_endpoint(
|
||||
"/machine/update/moonraker", ["POST"],
|
||||
self._handle_update_request)
|
||||
self.server.register_endpoint(
|
||||
"/machine/update/klipper", ["POST"],
|
||||
self._handle_update_request)
|
||||
self.server.register_endpoint(
|
||||
"/machine/update/system", ["POST"],
|
||||
self._handle_update_request)
|
||||
self.server.register_endpoint(
|
||||
"/machine/update/client", ["POST"],
|
||||
self._handle_update_request)
|
||||
self.server.register_endpoint(
|
||||
"/machine/update/status", ["GET"],
|
||||
self._handle_status_request)
|
||||
|
||||
# Register Ready Event
|
||||
self.server.register_event_handler(
|
||||
"server:klippy_ready", self._set_klipper_repo)
|
||||
|
||||
async def _set_klipper_repo(self):
|
||||
kinfo = self.server.get_klippy_info()
|
||||
if not kinfo:
|
||||
logging.info("No valid klippy info received")
|
||||
return
|
||||
kpath = kinfo['klipper_path']
|
||||
kversion = kinfo['software_version']
|
||||
env = kinfo['python_path']
|
||||
self.updaters['klipper'] = GitUpdater(
|
||||
self, "klipper", kpath, kversion, env)
|
||||
|
||||
async def _check_versions(self):
|
||||
for updater in self.updaters.values():
|
||||
await updater.check_remote_version()
|
||||
|
||||
async def _handle_update_request(self, web_request):
|
||||
app = web_request.get_endpoint().split("/")[-1]
|
||||
inc_deps = web_request.get_boolean('include_deps', False)
|
||||
if self.current_update:
|
||||
raise self.server.error("A current update is in progress")
|
||||
updater = self.updaters.get(app, None)
|
||||
if updater is None:
|
||||
raise self.server.error(f"Updater {app} not available")
|
||||
self.current_update = (app, id(web_request))
|
||||
try:
|
||||
await updater.update(inc_deps)
|
||||
except Exception:
|
||||
self.current_update = None
|
||||
raise
|
||||
self.current_update = None
|
||||
return "ok"
|
||||
|
||||
async def _handle_status_request(self, web_request):
|
||||
if web_request.get_boolean('refresh', False):
|
||||
await self._check_versions()
|
||||
vinfo = {}
|
||||
for name, updater in self.updaters.items():
|
||||
if hasattr(updater, "get_update_status"):
|
||||
vinfo[name] = updater.get_update_status()
|
||||
return {
|
||||
'version_info': vinfo,
|
||||
'busy': self.current_update is not None}
|
||||
|
||||
async def execute_cmd(self, cmd, timeout=10., notify=False):
|
||||
shell_command = self.server.lookup_plugin('shell_command')
|
||||
cb = self.notify_update_response if notify else None
|
||||
scmd = shell_command.build_shell_command(cmd, callback=cb)
|
||||
await scmd.run(timeout=timeout, verbose=notify)
|
||||
|
||||
async def execute_cmd_with_response(self, cmd, timeout=10.):
|
||||
shell_command = self.server.lookup_plugin('shell_command')
|
||||
scmd = shell_command.build_shell_command(cmd, None)
|
||||
return await scmd.run_with_response(timeout)
|
||||
|
||||
async def github_request(self, url, is_download=False):
|
||||
cto = rto = 5.
|
||||
content_type = "application/vnd.github.v3+json"
|
||||
if is_download:
|
||||
content_type = "application/zip"
|
||||
rto = 120.
|
||||
timeout = cto + rto + 2.
|
||||
request = HTTPRequest(url, headers={"Accept": content_type},
|
||||
connect_timeout=cto, request_timeout=rto)
|
||||
retries = 5
|
||||
while True:
|
||||
to = IOLoop.current().time() + timeout
|
||||
try:
|
||||
fut = self.http_client.fetch(request)
|
||||
resp = await tornado.gen.with_timeout(to, fut)
|
||||
except Exception as e:
|
||||
retries -= 1
|
||||
if not retries:
|
||||
raise
|
||||
logging.info(f"Github request error, retrying: {e}")
|
||||
continue
|
||||
if is_download:
|
||||
return resp.body
|
||||
decoded = json.loads(resp.body)
|
||||
return decoded
|
||||
|
||||
def notify_update_response(self, resp):
|
||||
resp = resp.strip()
|
||||
if isinstance(resp, bytes):
|
||||
resp = resp.decode()
|
||||
notification = {
|
||||
'message': resp,
|
||||
'application': None,
|
||||
'proc_id': None}
|
||||
if self.current_update is not None:
|
||||
notification['application'] = self.current_update[0]
|
||||
notification['proc_id'] = self.current_update[1]
|
||||
self.server.send_event(
|
||||
"update_manager:update_response", notification)
|
||||
|
||||
def close(self):
|
||||
self.http_client.close()
|
||||
|
||||
|
||||
class GitUpdater:
|
||||
def __init__(self, umgr, name, path, ver, env):
|
||||
self.server = umgr.server
|
||||
self.execute_cmd = umgr.execute_cmd
|
||||
self.execute_cmd_with_response = umgr.execute_cmd_with_response
|
||||
self.notify_update_response = umgr.notify_update_response
|
||||
self.github_request = umgr.github_request
|
||||
self.name = name
|
||||
self.repo_path = path
|
||||
self.env = env
|
||||
self.version = self.cur_hash = self.remote_hash = "?"
|
||||
self.is_valid = False
|
||||
self.is_dirty = ver.endswith("dirty")
|
||||
tag_version = "?"
|
||||
ver_match = re.match(r"v\d+\.\d+\.\d-\d+", ver)
|
||||
if ver_match:
|
||||
tag_version = ver_match.group()
|
||||
self.version = tag_version
|
||||
IOLoop.current().spawn_callback(self._init_repo)
|
||||
|
||||
def _get_version_info(self):
|
||||
ver_path = os.path.join(self.repo_path, "scripts/version.txt")
|
||||
vinfo = {}
|
||||
if os.path.isfile(ver_path):
|
||||
data = ""
|
||||
with open(ver_path, 'r') as f:
|
||||
data = f.read()
|
||||
try:
|
||||
entries = [e.strip() for e in data.split('\n') if e.strip()]
|
||||
vinfo = dict([i.split('=') for i in entries])
|
||||
vinfo = {k: tuple(re.findall(r"\d+", v)) for k, v in
|
||||
vinfo.items()}
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self._log_info(f"Version Info Found: {vinfo}")
|
||||
vinfo['version'] = tuple(re.findall(r"\d+", self.version))
|
||||
return vinfo
|
||||
|
||||
def _log_exc(self, msg, traceback=True):
|
||||
log_msg = f"Repo {self.name}: {msg}"
|
||||
if traceback:
|
||||
logging.exception(log_msg)
|
||||
else:
|
||||
logging.info(log_msg)
|
||||
return self.server.error(msg)
|
||||
|
||||
def _log_info(self, msg):
|
||||
log_msg = f"Repo {self.name}: {msg}"
|
||||
logging.info(log_msg)
|
||||
|
||||
def _notify_status(self, msg):
|
||||
log_msg = f"Repo {self.name}: {msg}"
|
||||
logging.debug(log_msg)
|
||||
self.notify_update_response(log_msg)
|
||||
|
||||
async def _init_repo(self):
|
||||
self.is_valid = False
|
||||
self.cur_hash = "?"
|
||||
try:
|
||||
branch = await self.execute_cmd_with_response(
|
||||
f"git -C {self.repo_path} rev-parse --abbrev-ref HEAD")
|
||||
origin = await self.execute_cmd_with_response(
|
||||
f"git -C {self.repo_path} remote get-url origin")
|
||||
hash = await self.execute_cmd_with_response(
|
||||
f"git -C {self.repo_path} rev-parse HEAD")
|
||||
except Exception:
|
||||
self._log_exc("Error retreiving git info")
|
||||
return
|
||||
|
||||
if not branch.startswith("fatal:"):
|
||||
self.cur_hash = hash
|
||||
if branch == "master":
|
||||
if origin == REPO_DATA[self.name]['origin']:
|
||||
self.is_valid = True
|
||||
self._log_info("Validity check for git repo passed")
|
||||
else:
|
||||
self._log_info(f"Invalid git origin '{origin}''")
|
||||
else:
|
||||
self._log_info("Git repo not on master branch")
|
||||
else:
|
||||
self._log_info(f"Invalid git repo at path '{self.repo_path}''")
|
||||
try:
|
||||
await self.check_remote_version()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def check_remote_version(self):
|
||||
repo_url = REPO_DATA[self.name]['repo_url']
|
||||
try:
|
||||
branch_info = await self.github_request(repo_url)
|
||||
except Exception:
|
||||
raise self._log_exc(f"Error retreiving github info")
|
||||
commit_hash = branch_info.get('commit', {}).get('sha', None)
|
||||
if commit_hash is None:
|
||||
self.is_valid = False
|
||||
self.upstream_version = "?"
|
||||
raise self._log_exc(f"Invalid github response", False)
|
||||
self._log_info(f"Received latest commit hash: {commit_hash}")
|
||||
self.remote_hash = commit_hash
|
||||
|
||||
async def update(self, update_deps=False):
|
||||
if not self.is_valid:
|
||||
raise self._log_exc("Update aborted, repo is not valid", False)
|
||||
if self.is_dirty:
|
||||
raise self._log_exc(
|
||||
"Update aborted, repo is has been modified", False)
|
||||
if self.remote_hash == "?":
|
||||
await self.check_remote_version()
|
||||
if self.remote_hash == self.cur_hash:
|
||||
# No need to update
|
||||
return
|
||||
self._notify_status("Updating Repo...")
|
||||
try:
|
||||
await self.execute_cmd(f"git -C {self.repo_path} pull -q")
|
||||
except Exception:
|
||||
raise self._log_exc("Error running 'git pull'")
|
||||
# Check Semantic Versions
|
||||
vinfo = self._get_version_info()
|
||||
cur_version = vinfo.get('version', ())
|
||||
update_deps |= cur_version < vinfo.get('deps_version', ())
|
||||
need_env_rebuild = cur_version < vinfo.get('env_version', ())
|
||||
if update_deps:
|
||||
await self._install_packages()
|
||||
await self._update_virtualenv(need_env_rebuild)
|
||||
if self.name == "moonraker":
|
||||
# Launch restart async so the request can return
|
||||
# before the server restarts
|
||||
IOLoop.current().spawn_callback(self.restart_service)
|
||||
else:
|
||||
await self.restart_service()
|
||||
|
||||
async def _install_packages(self):
|
||||
# Open install file file and read
|
||||
inst_script = REPO_DATA[self.name]['install_script']
|
||||
inst_path = os.path.join(self.repo_path, inst_script)
|
||||
if not os.path.isfile(inst_path):
|
||||
self._log_info(f"Unable to open install script: {inst_path}")
|
||||
return
|
||||
with open(inst_path, 'r') as f:
|
||||
data = f.read()
|
||||
packages = re.findall(r'PKGLIST="(.*)"', data)
|
||||
packages = [p.lstrip("${PKGLIST}").strip() for p in packages]
|
||||
if not packages:
|
||||
self._log_info(f"No packages found in script: {inst_path}")
|
||||
return
|
||||
# TODO: Log and notify that packages will be installed
|
||||
pkgs = " ".join(packages)
|
||||
logging.debug(f"Repo {self.name}: Detected Packages: {pkgs}")
|
||||
self._notify_status("Installing system dependencies...")
|
||||
# Install packages with apt-get
|
||||
try:
|
||||
await self.execute_cmd(
|
||||
f"{APT_CMD} update", timeout=300., notify=True)
|
||||
await self.execute_cmd(
|
||||
f"{APT_CMD} install --yes {pkgs}", timeout=3600.,
|
||||
notify=True)
|
||||
except Exception:
|
||||
self._log_exc("Error updating packages via apt-get")
|
||||
return
|
||||
|
||||
async def _update_virtualenv(self, rebuild_env=False):
|
||||
# Update python dependencies
|
||||
bin_dir = os.path.dirname(self.env)
|
||||
if rebuild_env:
|
||||
env_path = os.path.normpath(os.path.join(bin_dir, ".."))
|
||||
env_args = REPO_DATA[self.name]['venv_args']
|
||||
self._notify_status(f"Creating virtualenv at: {env_path}...")
|
||||
if os.path.exists(env_path):
|
||||
shutil.rmtree(env_path)
|
||||
try:
|
||||
await self.execute_cmd(
|
||||
f"virtualenv {env_args} {env_path}")
|
||||
except Exception:
|
||||
self._log_exc(f"Error creating virtualenv")
|
||||
return
|
||||
if not os.path.expanduser(self.env):
|
||||
raise self._log_exc("Failed to create new virtualenv", False)
|
||||
reqs = os.path.join(
|
||||
self.repo_path, REPO_DATA[self.name]['requirements'])
|
||||
if not os.path.isfile(reqs):
|
||||
self._log_exc(f"Invalid path to requirements_file '{reqs}'")
|
||||
return
|
||||
pip = os.path.join(bin_dir, "pip")
|
||||
self._notify_status("Updating python packages...")
|
||||
try:
|
||||
await self.execute_cmd(
|
||||
f"{pip} install -r {reqs}", timeout=1200., notify=True)
|
||||
except Exception:
|
||||
self._log_exc("Error updating python requirements")
|
||||
|
||||
async def restart_service(self):
|
||||
self._notify_status("Restarting Service...")
|
||||
try:
|
||||
await self.execute_cmd(f"sudo systemctl restart {self.name}")
|
||||
except Exception:
|
||||
raise self._log_exc("Error restarting service")
|
||||
|
||||
def get_update_status(self):
|
||||
return {
|
||||
'version': self.version,
|
||||
'current_hash': self.cur_hash,
|
||||
'remote_hash': self.remote_hash,
|
||||
'is_dirty': self.is_dirty,
|
||||
'is_valid': self.is_valid}
|
||||
|
||||
|
||||
class PackageUpdater:
|
||||
def __init__(self, umgr):
|
||||
self.server = umgr.server
|
||||
self.execute_cmd = umgr.execute_cmd
|
||||
self.notify_update_response = umgr.notify_update_response
|
||||
|
||||
async def check_remote_version(self):
|
||||
pass
|
||||
|
||||
async def update(self, *args):
|
||||
self.notify_update_response("Updating packages...")
|
||||
try:
|
||||
await self.execute_cmd(
|
||||
f"{APT_CMD} update", timeout=300., notify=True)
|
||||
await self.execute_cmd(
|
||||
f"{APT_CMD} upgrade --yes", timeout=3600., notify=True)
|
||||
except Exception:
|
||||
raise self.server.error("Error updating system packages")
|
||||
|
||||
class ClientUpdater:
|
||||
def __init__(self, umgr, repo, path):
|
||||
self.server = umgr.server
|
||||
self.github_request = umgr.github_request
|
||||
self.notify_update_response = umgr.notify_update_response
|
||||
self.repo = repo.strip().strip("/")
|
||||
self.name = self.repo.split("/")[-1]
|
||||
self.path = path
|
||||
self.version = self.remote_version = self.dl_url = "?"
|
||||
version_path = os.path.join(self.path, ".version")
|
||||
if os.path.isfile(os.path.join(self.path, ".version")):
|
||||
with open(version_path, "r") as f:
|
||||
v = f.read()
|
||||
self.version = v.strip()
|
||||
logging.info(f"\nInitializing repo '{self.name}',"
|
||||
f"\nversion: {self.version}"
|
||||
f"\npath: {self.path}")
|
||||
IOLoop.current().spawn_callback(self.check_remote_version)
|
||||
|
||||
async def check_remote_version(self):
|
||||
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
|
||||
try:
|
||||
result = await self.github_request(url)
|
||||
except Exception:
|
||||
logging.exception(f"Client {self.repo}: Github Request Error")
|
||||
result = {}
|
||||
self.remote_version = result.get('name', "?")
|
||||
release_assets = result.get('assets', [{}])[0]
|
||||
self.dl_url = release_assets.get('browser_download_url', "?")
|
||||
logging.info(
|
||||
f"Github client Info Received: {self.name}, "
|
||||
f"version: {self.remote_version} "
|
||||
f"url: {self.dl_url}")
|
||||
|
||||
async def update(self, *args):
|
||||
if self.remote_version == "?":
|
||||
await self.check_remote_version()
|
||||
if self.remote_version == "?":
|
||||
raise self.server.error(
|
||||
f"Client {self.repo}: Unable to locate update")
|
||||
if self.dl_url == "?":
|
||||
raise self.server.error(
|
||||
f"Client {self.repo}: Invalid download url")
|
||||
if self.version == self.remote_version:
|
||||
# Already up to date
|
||||
return
|
||||
if os.path.isdir(self.path):
|
||||
shutil.rmtree(self.path)
|
||||
os.mkdir(self.path)
|
||||
self.notify_update_response(f"Downloading Client: {self.name}")
|
||||
archive = await self.github_request(self.dl_url, is_download=True)
|
||||
with zipfile.ZipFile(io.BytesIO(archive)) as zf:
|
||||
zf.extractall(self.path)
|
||||
self.version = self.remote_version
|
||||
version_path = os.path.join(self.path, ".version")
|
||||
if not os.path.exists(version_path):
|
||||
with open(version_path, "w") as f:
|
||||
f.write(self.version)
|
||||
self.notify_update_response(f"Client Updated: {self.name}")
|
||||
|
||||
def get_update_status(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'version': self.version,
|
||||
'remote_version': self.remote_version
|
||||
}
|
||||
|
||||
def load_plugin(config):
|
||||
return UpdateManager(config)
|
Loading…
Reference in New Issue