From 2e6240a9105d29342cf15ef2875a7af9a8f8dd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 9 Jun 2015 13:35:03 +0200 Subject: [PATCH] Bundled Software Update Plugin --- .../plugins/softwareupdate/__init__.py | 462 ++++++++++++++++++ .../plugins/softwareupdate/exceptions.py | 37 ++ .../scripts/update-octoprint.py | 180 +++++++ .../static/css/softwareupdate.css | 1 + .../static/js/softwareupdate.js | 435 +++++++++++++++++ .../static/less/softwareupdate.less | 3 + .../templates/softwareupdate_settings.jinja2 | 86 ++++ .../softwareupdate/updaters/__init__.py | 8 + .../plugins/softwareupdate/updaters/pip.py | 37 ++ .../softwareupdate/updaters/python_updater.py | 14 + .../softwareupdate/updaters/update_script.py | 79 +++ src/octoprint/plugins/softwareupdate/util.py | 27 + .../softwareupdate/version_checks/__init__.py | 20 + .../version_checks/commandline.py | 52 ++ .../version_checks/git_commit.py | 88 ++++ .../version_checks/github_commit.py | 56 +++ .../version_checks/github_release.py | 91 ++++ .../version_checks/python_checker.py | 14 + 18 files changed, 1690 insertions(+) create mode 100644 src/octoprint/plugins/softwareupdate/__init__.py create mode 100644 src/octoprint/plugins/softwareupdate/exceptions.py create mode 100644 src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py create mode 100644 src/octoprint/plugins/softwareupdate/static/css/softwareupdate.css create mode 100644 src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js create mode 100644 src/octoprint/plugins/softwareupdate/static/less/softwareupdate.less create mode 100644 src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 create mode 100644 src/octoprint/plugins/softwareupdate/updaters/__init__.py create mode 100644 src/octoprint/plugins/softwareupdate/updaters/pip.py create mode 100644 src/octoprint/plugins/softwareupdate/updaters/python_updater.py create mode 100644 src/octoprint/plugins/softwareupdate/updaters/update_script.py create mode 100644 src/octoprint/plugins/softwareupdate/util.py create mode 100644 src/octoprint/plugins/softwareupdate/version_checks/__init__.py create mode 100644 src/octoprint/plugins/softwareupdate/version_checks/commandline.py create mode 100644 src/octoprint/plugins/softwareupdate/version_checks/git_commit.py create mode 100644 src/octoprint/plugins/softwareupdate/version_checks/github_commit.py create mode 100644 src/octoprint/plugins/softwareupdate/version_checks/github_release.py create mode 100644 src/octoprint/plugins/softwareupdate/version_checks/python_checker.py diff --git a/src/octoprint/plugins/softwareupdate/__init__.py b/src/octoprint/plugins/softwareupdate/__init__.py new file mode 100644 index 00000000..5129c022 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/__init__.py @@ -0,0 +1,462 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import octoprint.plugin + +import flask +import os +import threading +import time + +from . import version_checks, updaters, exceptions, util + + +from octoprint.server.util.flask import restricted_access +from octoprint.server import admin_permission +from octoprint.util import dict_merge +import octoprint.settings + + +##~~ Plugin + + +class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.AssetPlugin, + octoprint.plugin.TemplatePlugin): + def __init__(self): + self._update_in_progress = False + self._configured_checks_mutex = threading.Lock() + self._configured_checks = None + + self._version_cache = dict() + self._version_cache_ttl = 0 + + def initialize(self): + self._version_cache_ttl = self._settings.get_int(["cache_ttl"]) * 60 + + def _get_configured_checks(self): + with self._configured_checks_mutex: + if self._configured_checks is None: + self._configured_checks = self._settings.get(["checks"], merged=True) + update_check_hooks = self._plugin_manager.get_hooks("octoprint.plugin.softwareupdate.check_config") + for name, hook in update_check_hooks.items(): + try: + hook_checks = hook() + except: + self._logger.exception("Error while retrieving update information from plugin {name}".format(**locals())) + else: + for key, data in hook_checks.items(): + if key in self._configured_checks: + data = dict_merge(data, self._configured_checks[key]) + self._configured_checks[key] = data + + return self._configured_checks + + #~~ SettingsPlugin API + + def get_settings_defaults(self): + return { + "checks": { + "octoprint": { + "type": "github_release", + "user": "foosel", + "repo": "OctoPrint", + "update_script": "{{python}} \"{update_script}\" --python=\"{{python}}\" \"{{folder}}\" {{target}}".format(update_script=os.path.join(self._basefolder, "scripts", "update-octoprint.py")), + "restart": "octoprint" + }, + }, + + "octoprint_restart_command": None, + "environment_restart_command": None, + + "cache_ttl": 60, + } + + def on_settings_save(self, data): + super(SoftwareUpdatePlugin, self).on_settings_save(data) + self._version_cache_ttl = self._settings.get_int(["cache_ttl"]) * 60 + + #~~ BluePrint API + + @octoprint.plugin.BlueprintPlugin.route("/check", methods=["GET"]) + def check_for_update(self): + if "check" in flask.request.values: + check_targets = map(str.strip, flask.request.values["check"].split(",")) + else: + check_targets = None + + if "force" in flask.request.values and flask.request.values["force"] in octoprint.settings.valid_boolean_trues: + force = True + else: + force=False + + try: + information, update_available, update_possible = self.get_current_versions(check_targets=check_targets, force=force) + return flask.jsonify(dict(status="updatePossible" if update_available and update_possible else "updateAvailable" if update_available else "current", information=information)) + except exceptions.ConfigurationInvalid as e: + flask.make_response("Update not properly configured, can't proceed: %s" % e.message, 500) + + + @octoprint.plugin.BlueprintPlugin.route("/update", methods=["POST"]) + @restricted_access + @admin_permission.require(403) + def perform_update(self): + if self._printer.is_printing() or self._printer.is_paused(): + # do not update while a print job is running + flask.make_response("Printer is currently printing or paused", 409) + + if not "application/json" in flask.request.headers["Content-Type"]: + flask.make_response("Expected content-type JSON", 400) + + json_data = flask.request.json + + if "check" in json_data: + check_targets = map(str.strip, json_data["check"]) + else: + check_targets = None + + if "force" in json_data: + from octoprint.settings import valid_boolean_trues + force = (json_data["force"] in valid_boolean_trues) + else: + force = False + + to_be_checked, checks = self.perform_updates(check_targets=check_targets, force=force) + return flask.jsonify(dict(order=to_be_checked, checks=checks)) + + #~~ Asset API + + def get_assets(self): + return dict( + css=["css/softwareupdate.css"], + js=["js/softwareupdate.js"], + less=["less/softwareupdate.less"] + ) + + ##~~ TemplatePlugin API + + def get_template_configs(self): + return [ + dict(type="settings", name="Software Update") + ] + + #~~ Updater + + def get_current_versions(self, check_targets=None, force=False): + """ + Retrieves the current version information for all defined check_targets. Will retrieve information for all + available targets by default. + + :param check_targets: an iterable defining the targets to check, if not supplied defaults to all targets + """ + + checks = self._get_configured_checks() + if check_targets is None: + check_targets = checks.keys() + + update_available = False + update_possible = False + information = dict() + + for target, check in checks.items(): + if not target in check_targets: + continue + + try: + target_information, target_update_available, target_update_possible = self._get_current_version(target, check, force=force) + if target_information is None: + continue + except exceptions.UnknownCheckType: + self._logger.warn("Unknown update check type for %s" % target) + continue + + target_information = dict_merge(dict(local=dict(name="unknown", value="unknown"), remote=dict(name="unknown", value="unknown")), target_information) + + update_available = update_available or target_update_available + update_possible = update_possible or (target_update_possible and target_update_available) + information[target] = dict(updateAvailable=target_update_available, updatePossible=target_update_possible, information=target_information) + + if "displayName" in check: + information[target]["displayName"] = check["displayName"] + + if "displayVersion" in check: + from octoprint._version import get_versions + octoprint_version = get_versions()["version"] + local_name = target_information["local"]["name"] + local_value = target_information["local"]["value"] + information[target]["displayVersion"] = check["displayVersion"].format(octoprint_version=octoprint_version, local_name=local_name, local_value=local_value) + + return information, update_available, update_possible + + def _get_current_version(self, target, check, force=False): + """ + Determines the current version information for one target based on its check configuration. + """ + + if target in self._version_cache and not force: + timestamp, information, update_available, update_possible = self._version_cache[target] + if timestamp + self._version_cache_ttl >= time.time(): + return information, update_available, update_possible + + information = dict() + update_available = False + + try: + version_checker = self._get_version_checker(target, check) + information, is_current = version_checker.get_latest(target, check) + if information is not None and not is_current: + update_available = True + except exceptions.UnknownCheckType: + self._logger.warn("Unknown check type %s for %s" % (check["type"], target)) + update_possible = False + except: + self._logger.exception("Could not check %s for updates" % target) + update_possible = False + else: + try: + updater = self._get_updater(target, check) + update_possible = updater.can_perform_update(target, check) + except: + update_possible = False + + self._version_cache[target] = (time.time(), information, update_available, update_possible) + return information, update_available, update_possible + + def _send_client_message(self, message_type, data=None): + self._plugin_manager.send_plugin_message("softwareupdate", dict(type=message_type, data=data)) + + def perform_updates(self, check_targets=None, force=False): + """ + Performs the updates for the given check_targets. Will update all possible targets by default. + + :param check_targets: an iterable defining the targets to update, if not supplied defaults to all targets + """ + + checks = self._get_configured_checks() + if check_targets is None: + check_targets = checks.keys() + to_be_updated = sorted(set(check_targets) & set(checks.keys())) + if "octoprint" in to_be_updated: + to_be_updated.remove("octoprint") + tmp = ["octoprint"] + to_be_updated + to_be_updated = tmp + + updater_thread = threading.Thread(target=self._update_worker, args=(checks, to_be_updated, force)) + updater_thread.daemon = False + updater_thread.start() + + return to_be_updated, dict((key, check["displayName"] if "displayName" in check else key) for key, check in checks.items() if key in to_be_updated) + + def _update_worker(self, checks, check_targets, force): + + restart_type = None + + try: + self._update_in_progress = True + + target_results = dict() + error = False + + ### iterate over all configured targets + + for target in check_targets: + if not target in checks: + continue + check = checks[target] + + if "enabled" in check and not check["enabled"]: + continue + + if not target in check_targets: + continue + + target_error, target_result = self._perform_update(target, check, force) + error = error or target_error + if target_result is not None: + target_results[target] = target_result + + if "restart" in check: + target_restart_type = check["restart"] + elif "pip" in check: + target_restart_type = "octoprint" + + # if our update requires a restart we have to determine which type + if restart_type is None or (restart_type == "octoprint" and target_restart_type == "environment"): + restart_type = target_restart_type + + finally: + # we might have needed to update the config, so we'll save that now + self._settings.save() + + # also, we are now longer updating + self._update_in_progress = False + + if error: + # if there was an unignorable error, we just return error + self._send_client_message("error", dict(results=target_results)) + + else: + # otherwise the update process was a success, but we might still have to restart + if restart_type is not None and restart_type in ("octoprint", "environment"): + # one of our updates requires a restart of either type "octoprint" or "environment". Let's see if + # we can actually perform that + restart_command = self._settings.get(["%s_restart_command" % restart_type]) + + if restart_command is not None: + self._send_client_message("restarting", dict(restart_type=restart_type, results=target_results)) + try: + self._perform_restart(restart_command) + except exceptions.RestartFailed: + self._send_client_message("restart_failed", dict(restart_type=restart_type, results=target_results)) + else: + # we don't have this restart type configured, we'll have to display a message that a manual + # restart is needed + self._send_client_message("restart_manually", dict(restart_type=restart_type, results=target_results)) + else: + self._send_client_message("success", dict(results=target_results)) + + def _perform_update(self, target, check, force): + information, update_available, update_possible = self._get_current_version(target, check) + + if not update_available and not force: + return False, None + + if not update_possible: + self._logger.warn("Cannot perform update for %s, update type is not fully configured" % target) + return False, None + + # determine the target version to update to + target_version = information["remote"]["value"] + target_error = False + + ### The actual update procedure starts here... + + try: + self._logger.info("Starting update of %s to %s..." % (target, target_version)) + self._send_client_message("updating", dict(target=target, version=target_version)) + updater = self._get_updater(target, check) + if updater is None: + raise exceptions.UnknownUpdateType() + + update_result = updater.perform_update(target, check, target_version) + target_result = ("success", update_result) + self._logger.info("Update of %s to %s successful!" % (target, target_version)) + + except exceptions.UnknownUpdateType: + self._logger.warn("Update of %s can not be performed, unknown update type" % target) + self._send_client_message("update_failed", dict(target=target, version=target_version, reason="Unknown update type")) + return False, None + + except Exception as e: + self._logger.exception("Update of %s can not be performed" % target) + if not "ignorable" in check or not check["ignorable"]: + target_error = True + + if isinstance(e, exceptions.UpdateError): + target_result = ("failed", e.data) + self._send_client_message("update_failed", dict(target=target, version=target_version, reason=e.data)) + else: + target_result = ("failed", None) + self._send_client_message("update_failed", dict(target=target, version=target_version, reason="unknown")) + + else: + # make sure that any external changes to config.yaml are loaded into the system + self._settings.load() + + # persist the new version if necessary for check type + if check["type"] == "github_commit": + checks = self._settings.get(["checks"], merged=True) + if target in checks: + # TODO make this cleaner, right now it saves too much to disk + checks[target]["current"] = target_version + self._settings.set(["checks"], checks) + + # we have to save here (even though that makes us save quite often) since otherwise the next + # load will overwrite our changes we just made + self._settings.save() + + return target_error, target_result + + def _perform_restart(self, restart_command): + """ + Performs a restart using the supplied restart_command. + """ + + self._logger.info("Restarting...") + try: + util.execute(restart_command) + except exceptions.ScriptError as e: + self._logger.exception("Error while restarting") + self._logger.warn("Restart stdout:\n%s" % e.stdout) + self._logger.warn("Restart stderr:\n%s" % e.stderr) + raise exceptions.RestartFailed() + + def _get_version_checker(self, target, check): + """ + Retrieves the version checker to use for given target and check configuration. Will raise an UnknownCheckType + if version checker cannot be determined. + """ + + if not "type" in check: + raise exceptions.ConfigurationInvalid("no check type defined") + + if target == "octoprint": + from octoprint._version import get_versions + from flask.ext.babel import gettext + check["displayName"] = gettext("OctoPrint") + check["displayVersion"] = "{octoprint_version}" + check["current"] = get_versions()["version"] + + check_type = check["type"] + if check_type == "github_release": + return version_checks.github_release + elif check_type == "github_commit": + return version_checks.github_commit + elif check_type == "git_commit": + return version_checks.git_commit + elif check_type == "commandline": + return version_checks.commandline + elif check_type == "python_checker": + return version_checks.python_checker + else: + raise exceptions.UnknownCheckType() + + def _get_updater(self, target, check): + """ + Retrieves the updater for the given target and check configuration. Will raise an UnknownUpdateType if updater + cannot be determined. + """ + + if "update_script" in check: + return updaters.update_script + elif "pip" in check: + return updaters.pip + elif "python_updater" in check: + return updaters.python_updater + else: + raise exceptions.UnknownUpdateType() + +__plugin_name__ = "Software Update" +__plugin_author__ = "Gina Häußge" +__plugin_url__ = "https://github.com/foosel/OctoPrint/wiki/Plugin:-Software-Update" +__plugin_description__ = "Allows receiving update notifications and performing updates of OctoPrint and plugins" +__plugin_license__ = "AGPLv3" +def __plugin_load__(): + global __plugin_implementation__ + __plugin_implementation__ = SoftwareUpdatePlugin() + + global __plugin_helpers__ + __plugin_helpers__ = dict( + version_checks=version_checks, + updaters=updaters, + exceptions=exceptions, + util=util + ) + + diff --git a/src/octoprint/plugins/softwareupdate/exceptions.py b/src/octoprint/plugins/softwareupdate/exceptions.py new file mode 100644 index 00000000..e4b6ecdf --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/exceptions.py @@ -0,0 +1,37 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +class NoUpdateAvailable(Exception): + pass + +class UpdateAlreadyInProgress(Exception): + pass + +class UnknownUpdateType(Exception): + pass + +class UnknownCheckType(Exception): + pass + +class UpdateError(Exception): + def __init__(self, message, data): + self.message = message + self.data = data + +class ScriptError(Exception): + def __init__(self, returncode, stdout, stderr): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + +class RestartFailed(Exception): + pass + +class ConfigurationInvalid(Exception): + pass + diff --git a/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py b/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py new file mode 100644 index 00000000..7ccef16d --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py @@ -0,0 +1,180 @@ +#!/bin/env python +from __future__ import absolute_import + +__author__ = "Gina Haeussge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import errno +import subprocess +import sys + + +def _get_git_executables(): + GITS = ["git"] + if sys.platform == "win32": + GITS = ["git.cmd", "git.exe"] + return GITS + + +def _git(args, cwd, hide_stderr=False, verbose=False, git_executable=None): + if git_executable is not None: + commands = [git_executable] + else: + commands = _get_git_executables() + + for c in commands: + try: + p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None)) + break + except EnvironmentError: + e = sys.exc_info()[1] + if e.errno == errno.ENOENT: + continue + if verbose: + print("unable to run %s" % args[0]) + print(e) + return None, None + else: + if verbose: + print("unable to find command, tried %s" % (commands,)) + return None, None + + stdout = p.communicate()[0].strip() + if sys.version >= '3': + stdout = stdout.decode() + + if p.returncode != 0: + if verbose: + print("unable to run %s (error)" % args[0]) + + return p.returncode, stdout + + +def _python(args, cwd, python_executable, sudo=False): + command = [python_executable] + args + if sudo: + command = ["sudo"] + command + try: + p = subprocess.Popen(command, cwd=cwd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + except: + return None, None + + stdout = p.communicate()[0].strip() + if sys.version >= "3": + stdout = stdout.decode() + + return p.returncode, stdout + + +def update_source(git_executable, folder, target, force=False): + print(">>> Running: git diff --shortstat") + returncode, stdout = _git(["diff", "--shortstat"], folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Could not update, \"git diff\" failed with returncode %d: %s" % (returncode, stdout)) + if stdout and stdout.strip(): + # we got changes in the working tree, maybe from the user, so we'll now rescue those into a patch + import time + import os + timestamp = time.strftime("%Y%m%d%H%M") + patch = os.path.join(folder, "%s-preupdate.patch" % timestamp) + + print(">>> Running: git diff and saving output to %s" % timestamp) + returncode, stdout = _git(["diff"], folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Could not update, installation directory was dirty and state could not be persisted as a patch to %s" % patch) + + with open(patch, "wb") as f: + f.write(stdout) + + print(">>> Running: git reset --hard") + returncode, stdout = _git(["reset", "--hard"], folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Could not update, \"git reset --hard\" failed with returncode %d: %s" % (returncode, stdout)) + + print(">>> Running: git pull") + returncode, stdout = _git(["pull"], folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Could not update, \"git pull\" failed with returncode %d: %s" % (returncode, stdout)) + print(stdout) + + if force: + reset_command = ["reset"] + reset_command += [target] + + print(">>> Running: git %s" % " ".join(reset_command)) + returncode, stdout = _git(reset_command, folder, git_executable=git_executable) + if returncode != 0: + raise RuntimeError("Error while updating, \"git %s\" failed with returncode %d: %s" % (" ".join(reset_command), returncode, stdout)) + print(stdout) + + +def install_source(python_executable, folder, user=False, sudo=False): + print(">>> Running: python setup.py clean") + returncode, stdout = _python(["setup.py", "clean"], folder, python_executable) + if returncode != 0: + print("\"python setup.py clean\" failed with returncode %d: %s" % (returncode, stdout)) + print("Continuing anyways") + print(stdout) + + print(">>> Running: python setup.py install") + args = ["setup.py", "install"] + if user: + args.append("--user") + returncode, stdout = _python(args, folder, python_executable, sudo=sudo) + if returncode != 0: + raise RuntimeError("Could not update, \"python setup.py install\" failed with returncode %d: %s" % (returncode, stdout)) + print(stdout) + + +def parse_arguments(): + import argparse + + parser = argparse.ArgumentParser(prog="update-octoprint.py") + + parser.add_argument("--git", action="store", type=str, dest="git_executable", + help="Specify git executable to use") + parser.add_argument("--python", action="store", type=str, dest="python_executable", + help="Specify python executable to use") + parser.add_argument("--force", action="store_true", dest="force", + help="Set this to force the update to only the specified version (nothing newer)") + parser.add_argument("--sudo", action="store_true", dest="sudo", + help="Install with sudo") + parser.add_argument("--user", action="store_true", dest="user", + help="Install to the user site directory instead of the general site directory") + parser.add_argument("folder", type=str, + help="Specify the base folder of the OctoPrint installation to update") + parser.add_argument("target", type=str, + help="Specify the commit or tag to which to update") + + args = parser.parse_args() + + return args + +def main(): + args = parse_arguments() + + git_executable = None + if args.git_executable: + git_executable = args.git_executable + + python_executable = sys.executable + if args.python_executable: + python_executable = args.python_executable + + folder = args.folder + target = args.target + + import os + if not os.access(folder, os.W_OK): + raise RuntimeError("Could not update, base folder is not writable") + + update_source(git_executable, folder, target, force=args.force) + install_source(python_executable, folder, user=args.user, sudo=args.sudo) + +if __name__ == "__main__": + main() diff --git a/src/octoprint/plugins/softwareupdate/static/css/softwareupdate.css b/src/octoprint/plugins/softwareupdate/static/css/softwareupdate.css new file mode 100644 index 00000000..3fc6f48c --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/static/css/softwareupdate.css @@ -0,0 +1 @@ +td.settings_plugin_softwareupdate_column_update{width:16px} \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js b/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js new file mode 100644 index 00000000..b8f0c3c6 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js @@ -0,0 +1,435 @@ +$(function() { + function SoftwareUpdateViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.printerState = parameters[1]; + self.settings = parameters[2]; + self.popup = undefined; + + self.updateInProgress = false; + self.waitingForRestart = false; + self.restartTimeout = undefined; + + self.currentlyBeingUpdated = []; + + self.config_restartCommand = ko.observable(); + self.config_rebootCommand = ko.observable(); + self.config_cacheTtl = ko.observable(); + + self.configurationDialog = $("#settings_plugin_softwareupdate_configurationdialog"); + + self.versions = new ItemListHelper( + "plugin.softwareupdate.versions", + { + "name": function(a, b) { + // sorts ascending, puts octoprint first + if (a.key.toLocaleLowerCase() == "octoprint") return -1; + if (b.key.toLocaleLowerCase() == "octoprint") return 1; + + if (a.displayName.toLocaleLowerCase() < b.displayName.toLocaleLowerCase()) return -1; + if (a.displayName.toLocaleLowerCase() > b.displayName.toLocaleLowerCase()) return 1; + return 0; + } + }, + {}, + "name", + [], + [], + 5 + ); + + self.onUserLoggedIn = function() { + self.performCheck(); + }; + + self._showPopup = function(options, eventListeners) { + if (self.popup !== undefined) { + self.popup.remove(); + } + self.popup = new PNotify(options); + + if (eventListeners) { + var popupObj = self.popup.get(); + _.each(eventListeners, function(value, key) { + popupObj.on(key, value); + }) + } + }; + + self._updatePopup = function(options) { + if (self.popup === undefined) { + self._showPopup(options); + } else { + self.popup.update(options); + } + }; + + self.showPluginSettings = function() { + self._copyConfig(); + self.configurationDialog.modal(); + }; + + self.savePluginSettings = function() { + var data = { + plugins: { + softwareupdate: { + octoprint_restart_command: self.config_restartCommand(), + environment_restart_command: self.config_rebootCommand(), + cache_ttl: parseInt(self.config_cacheTtl()) + } + } + }; + self.settings.saveData(data, function() { self.configurationDialog.modal("hide"); self._copyConfig(); }); + }; + + self._copyConfig = function() { + self.config_restartCommand(self.settings.settings.plugins.softwareupdate.octoprint_restart_command()); + self.config_rebootCommand(self.settings.settings.plugins.softwareupdate.environment_restart_command()); + self.config_cacheTtl(self.settings.settings.plugins.softwareupdate.cache_ttl()); + }; + + self.performCheck = function(showIfNothingNew, force, ignoreSeen) { + if (!self.loginState.isUser()) return; + + var url = PLUGIN_BASEURL + "softwareupdate/check"; + if (force) { + url += "?force=true"; + } + + $.ajax({ + url: url, + type: "GET", + dataType: "json", + success: function(data) { + var versions = []; + _.each(data.information, function(value, key) { + value["key"] = key; + + if (!value.hasOwnProperty("displayName") || value.displayName == "") { + value.displayName = value.key; + } + if (!value.hasOwnProperty("displayVersion") || value.displayVersion == "") { + value.displayVersion = value.information.local.name; + } + + versions.push(value); + }); + self.versions.updateItems(versions); + + if (data.status == "updateAvailable" || data.status == "updatePossible") { + var text = gettext("There are updates available for the following components:"); + + text += "
    "; + _.each(self.versions.items(), function(update_info) { + if (update_info.updateAvailable) { + var displayName = update_info.key; + if (update_info.hasOwnProperty("displayName")) { + displayName = update_info.displayName; + } + text += "
  • " + displayName + (update_info.updatePossible ? " " : "") + "
  • "; + } + }); + text += "
"; + + text += "" + gettext("Those components marked with can be updated directly.") + ""; + + var options = { + title: gettext("Update Available"), + text: text, + hide: false + }; + var eventListeners = {}; + + if (data.status == "updatePossible" && self.loginState.isAdmin()) { + // if user is admin, add action buttons + options["confirm"] = { + confirm: true, + buttons: [{ + text: gettext("Ignore"), + click: function() { + self._markNotificationAsSeen(data.information); + self._showPopup({ + text: gettext("You can make this message display again via \"Settings\" > \"SoftwareUpdate\" > \"Check for update now\"") + }); + } + }, { + text: gettext("Update now"), + addClass: "btn-primary", + click: self.update + }] + }; + options["buttons"] = { + closer: false, + sticker: false + }; + } + + if (ignoreSeen || !self._hasNotificationBeenSeen(data.information)) { + self._showPopup(options, eventListeners); + } + } else if (data.status == "current" && showIfNothingNew) { + self._showPopup({ + title: gettext("Everything is up-to-date"), + hide: false, + type: "success" + }); + } + } + }); + }; + + self._markNotificationAsSeen = function(data) { + if (!Modernizr.localstorage) + return false; + localStorage["plugin.softwareupdate.seen_information"] = JSON.stringify(self._informationToRemoteVersions(data)); + }; + + self._hasNotificationBeenSeen = function(data) { + if (!Modernizr.localstorage) + return false; + + if (localStorage["plugin.softwareupdate.seen_information"] == undefined) + return false; + + var knownData = JSON.parse(localStorage["plugin.softwareupdate.seen_information"]); + var freshData = self._informationToRemoteVersions(data); + + var hasBeenSeen = true; + _.each(freshData, function(value, key) { + if (!_.has(knownData, key) || knownData[key] != freshData[key]) { + hasBeenSeen = false; + } + }); + return hasBeenSeen; + }; + + self._informationToRemoteVersions = function(data) { + var result = {}; + _.each(data, function(value, key) { + result[key] = value.information.remote.value; + }); + return result; + }; + + self.performUpdate = function(force) { + self.updateInProgress = true; + + var options = { + title: gettext("Updating..."), + text: gettext("Now updating, please wait."), + icon: "icon-cog icon-spin", + hide: false, + buttons: { + closer: false, + sticker: false + } + }; + self._showPopup(options); + + $.ajax({ + url: PLUGIN_BASEURL + "softwareupdate/update", + type: "POST", + dataType: "json", + contentType: "application/json; charset=UTF-8", + data: JSON.stringify({force: (force == true)}), + error: function() { + self.updateInProgress = false; + self._showPopup({ + title: gettext("Update not started!"), + text: gettext("The update could not be started. Is it already active? Please consult the log for details."), + type: "error", + hide: false, + buttons: { + sticker: false + } + }); + }, + success: function(data) { + self.currentlyBeingUpdated = data.checks; + } + }); + }; + + self.update = function(force) { + if (self.updateInProgress) return; + if (!self.loginState.isAdmin()) return; + + force = (force == true); + + if (self.printerState.isPrinting()) { + self._showPopup({ + title: gettext("Can't update while printing"), + text: gettext("A print job is currently in progress. Updating will be prevented until it is done."), + type: "error" + }); + } else { + $("#confirmation_dialog .confirmation_dialog_message").text(gettext("This will update your OctoPrint installation and restart the server.")); + $("#confirmation_dialog .confirmation_dialog_acknowledge").unbind("click"); + $("#confirmation_dialog .confirmation_dialog_acknowledge").click(function(e) { + e.preventDefault(); + $("#confirmation_dialog").modal("hide"); + self.performUpdate(force); + }); + $("#confirmation_dialog").modal("show"); + } + + }; + + self.onServerDisconnect = function() { + if (self.restartTimeout !== undefined) { + clearTimeout(self.restartTimeout); + } + return true; + }; + + self.onDataUpdaterReconnect = function() { + if (self.waitingForRestart) { + self.waitingForRestart = false; + + var options = { + title: gettext("Restart successful!"), + text: gettext("The server was restarted successfully. The page will now reload automatically."), + type: "success", + hide: false + }; + self._showPopup(options); + self.updateInProgress = false; + + var delay = 5 + Math.floor(Math.random() * 5) + 1; + setTimeout(function() {location.reload(true);}, delay * 1000); + } + }; + + self.onDataUpdaterPluginMessage = function(plugin, data) { + if (plugin != "softwareupdate") { + return; + } + + var messageType = data.type; + var messageData = data.data; + + var options = undefined; + + switch (messageType) { + case "updating": { + console.log(JSON.stringify(messageData)); + + var name = self.currentlyBeingUpdated[messageData.target]; + if (name == undefined) { + name = messageData.target; + } + + self._updatePopup({ + text: _.sprintf(gettext("Now updating %(name)s to %(version)s"), {name: name, version: messageData.version}) + }); + break; + } + case "restarting": { + console.log(JSON.stringify(messageData)); + + options = { + title: gettext("Update successful, restarting!"), + text: gettext("The update finished successfully and the server will now be restarted."), + type: "success", + hide: false, + buttons: { + sticker: false + } + }; + + self.waitingForRestart = true; + self.restartTimeout = setTimeout(function() { + self._showPopup({ + title: gettext("Restart failed"), + text: gettext("The server apparently did not restart by itself, you'll have to do it manually. Please consult the log file on what went wrong."), + type: "error", + hide: false, + buttons: { + sticker: false + } + }); + self.waitingForRestart = false; + }, 20000); + + break; + } + case "restart_manually": { + console.log(JSON.stringify(messageData)); + + var restartType = messageData.restart_type; + var text = gettext("The update finished successfully, please restart OctoPrint now."); + if (restartType == "environment") { + text = gettext("The update finished successfully, please reboot the server now."); + } + + options = { + title: gettext("Update successful, restart required!"), + text: text, + type: "success", + hide: false, + buttons: { + sticker: false + } + }; + self.updateInProgress = false; + break; + } + case "restart_failed": { + var restartType = messageData.restart_type; + var text = gettext("Restarting OctoPrint failed, please restart it manually. You might also want to consult the log file on what went wrong here."); + if (restartType == "environment") { + text = gettext("Rebooting the server failed, please reboot it manually. You might also want to consult the log file on what went wrong here."); + } + + options = { + title: gettext("Restart failed"), + test: gettext("The server apparently did not restart by itself, you'll have to do it manually. Please consult the log file on what went wrong."), + type: "error", + hide: false, + buttons: { + sticker: false + } + }; + self.waitingForRestart = false; + self.updateInProgress = false; + break; + } + case "success": { + options = { + title: gettext("Update successful!"), + text: gettext("The update finished successfully."), + type: "success", + hide: false, + buttons: { + sticker: false + } + }; + self.updateInProgress = false; + break; + } + case "error": { + self._showPopup({ + title: gettext("Update failed!"), + text: gettext("The update did not finish successfully. Please consult the log for details."), + type: "error", + hide: false, + buttons: { + sticker: false + } + }); + self.updateInProgress = false; + break; + } + } + + if (options != undefined) { + self._showPopup(options); + } + }; + + } + + // view model class, parameters for constructor, container to bind to + ADDITIONAL_VIEWMODELS.push([SoftwareUpdateViewModel, ["loginStateViewModel", "printerStateViewModel", "settingsViewModel"], document.getElementById("settings_plugin_softwareupdate")]); +}); \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/static/less/softwareupdate.less b/src/octoprint/plugins/softwareupdate/static/less/softwareupdate.less new file mode 100644 index 00000000..50939bae --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/static/less/softwareupdate.less @@ -0,0 +1,3 @@ +td.settings_plugin_softwareupdate_column_update { + width: 16px; +} \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 b/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 new file mode 100644 index 00000000..e8bcc46c --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/templates/softwareupdate_settings.jinja2 @@ -0,0 +1,86 @@ +
+ +
+ +

{{ _('Current versions') }}

+ + + + + + + + +
+ + + :
+ + {{ _('Local:') }}
+ {{ _('Remote:') }} +
+
+ + + + +
+ +
+ + +
+
+ + + diff --git a/src/octoprint/plugins/softwareupdate/updaters/__init__.py b/src/octoprint/plugins/softwareupdate/updaters/__init__.py new file mode 100644 index 00000000..206cccd1 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/updaters/__init__.py @@ -0,0 +1,8 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +from . import pip, python_updater, update_script \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/updaters/pip.py b/src/octoprint/plugins/softwareupdate/updaters/pip.py new file mode 100644 index 00000000..cba3dfbb --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/updaters/pip.py @@ -0,0 +1,37 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import logging + +try: + import pip as _pip +except: + _pip = None + + +def can_perform_update(target, check): + return "pip" in check and _pip is not None + + +def perform_update(target, check, target_version): + logger = logging.getLogger("octoprint.plugins.softwareupdate.updaters.pip") + + install_arg = check["pip"].format(target_version=target_version) + + logger.debug("Target: %s, executing pip install %s" % (target, install_arg)) + pip_args = ["install", check["pip"].format(target_version=target_version)] + _pip.main(pip_args) + + if "force_reinstall" in check and check["force_reinstall"]: + # if force_reinstall is true, we need to install the package a second time, this time forcing its reinstall + # without forcing its dependencies too + logger.debug("Target. %s, executing pip install %s --ignore-reinstalled --force-reinstall --no-deps" % (target, install_arg)) + pip_args += ["--ignore-installed", "--force-reinstall", "--no-deps"] + _pip.main(pip_args) + + return "ok" diff --git a/src/octoprint/plugins/softwareupdate/updaters/python_updater.py b/src/octoprint/plugins/softwareupdate/updaters/python_updater.py new file mode 100644 index 00000000..5c9c1d9c --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/updaters/python_updater.py @@ -0,0 +1,14 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +def can_perform_update(target, check): + return "python_updater" in check and check["python_updater"] is not None + + +def perform_update(target, check, target_version): + return check["python_updater"].perform_update(target, check, target_version) \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/updaters/update_script.py b/src/octoprint/plugins/softwareupdate/updaters/update_script.py new file mode 100644 index 00000000..4d6f69d4 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/updaters/update_script.py @@ -0,0 +1,79 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import sys +import logging + +from ..exceptions import ScriptError, ConfigurationInvalid, UpdateError +from ..util import execute + + +def can_perform_update(target, check): + return "update_script" in check and ("checkout_folder" in check or "update_folder" in check) + + +def perform_update(target, check, target_version): + logger = logging.getLogger("octoprint.plugins.softwareupdate.updaters.update_script") + + if not can_perform_update(target, check): + raise ConfigurationInvalid("checkout_folder and update_folder are missing for update target %s, one is needed" % target) + + update_script = check["update_script"] + folder = check["update_folder"] if "update_folder" in check else check["checkout_folder"] + pre_update_script = check["pre_update_script"] if "pre_update_script" in check else None + post_update_script = check["post_update_script"] if "post_update_script" in check else None + + update_stdout = "" + update_stderr = "" + + ### pre update + + if pre_update_script is not None: + logger.debug("Target: %s, running pre-update script: %s" % (target, pre_update_script)) + try: + returncode, stdout, stderr = execute(pre_update_script, cwd=folder) + update_stdout += stdout + update_stderr += stderr + except ScriptError as e: + logger.exception("Target: %s, error while executing pre update script, got returncode %r" % (target, e.returncode)) + logger.warn("Target: %s, pre-update stdout:\n%s" % (target, e.stdout)) + logger.warn("Target: %s, pre-update stderr:\n%s" % (target, e.stderr)) + + ### update + + try: + update_command = update_script.format(python=sys.executable, folder=folder, target=target_version) + + logger.debug("Target %s, running update script: %s" % (target, update_command)) + returncode, stdout, stderr = execute(update_command, cwd=folder) + update_stdout += stdout + update_stderr += stderr + except ScriptError as e: + logger.exception("Target: %s, error while executing update script, got returncode %r" % (target, e.returncode)) + logger.warn("Target: %s, update stdout:\n%s" % (target, e.stdout)) + logger.warn("Target: %s, update stderr:\n%s" % (target, e.stderr)) + raise UpdateError("Error while executing update script for %s", (e.stdout, e.stderr)) + + ### post update + + if post_update_script is not None: + logger.debug("Target: %s, running post-update script %s..." % (target, post_update_script)) + try: + returncode, stdout, stderr = execute(post_update_script, cwd=folder) + update_stdout += stdout + update_stderr += stderr + except ScriptError as e: + logger.exception("Target: %s, error while executing post update script, got returncode %r" % (target, e.returncode)) + logger.warn("Target: %s, post-update stdout:\n%s" % (target, e.stdout)) + logger.warn("Target: %s, post-update stderr:\n%s" % (target, e.stderr)) + + logger.debug("Target: %s, update stdout:\n%s" % (target, update_stdout)) + logger.debug("Target: %s, update stderr:\n%s" % (target, update_stderr)) + + ### result + + return update_stdout, update_stderr diff --git a/src/octoprint/plugins/softwareupdate/util.py b/src/octoprint/plugins/softwareupdate/util.py new file mode 100644 index 00000000..fa3fe5ac --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/util.py @@ -0,0 +1,27 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +from .exceptions import ScriptError + + +def execute(command, cwd=None, evaluate_returncode=True): + import sarge + p = None + + try: + p = sarge.run(command, cwd=cwd, stdout=sarge.Capture(), stderr=sarge.Capture()) + except: + returncode = p.returncode if p is not None else None + stdout = p.stdout.text if p is not None and p.stdout is not None else "" + stderr = p.stderr.text if p is not None and p.stderr is not None else "" + raise ScriptError(returncode, stdout, stderr) + + if evaluate_returncode and p.returncode != 0: + raise ScriptError(p.returncode, p.stdout.text, p.stderr.text) + + return p.returncode, p.stdout.text, p.stderr.text diff --git a/src/octoprint/plugins/softwareupdate/version_checks/__init__.py b/src/octoprint/plugins/softwareupdate/version_checks/__init__.py new file mode 100644 index 00000000..34c99fa5 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/version_checks/__init__.py @@ -0,0 +1,20 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +from . import commandline, git_commit, github_commit, github_release, python_checker + +def log_github_ratelimit(logger, r): + ratelimit = r.headers["X-RateLimit-Limit"] if "X-RateLimit-Limit" in r.headers else "?" + remaining = r.headers["X-RateLimit-Remaining"] if "X-RateLimit-Remaining" in r.headers else "?" + reset = r.headers["X-RateLimit-Reset"] if "X-RateLimit-Reset" in r.headers else None + try: + import time + reset = time.strftime("%Y-%m-%d %H:%M", time.gmtime(int(reset))) + except: + reset = "?" + + logger.debug("Github rate limit: %s/%s, reset at %s" % (remaining, ratelimit, reset)) \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/version_checks/commandline.py b/src/octoprint/plugins/softwareupdate/version_checks/commandline.py new file mode 100644 index 00000000..2cefc1c3 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/version_checks/commandline.py @@ -0,0 +1,52 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import logging + +from ..exceptions import ConfigurationInvalid +from ..util import execute + +def get_latest(target, check): + if not "command" in check: + raise ConfigurationInvalid("Update configuration for %s of type commandline needs command defined" % target) + + returncode, stdout, stderr = execute(check["command"], evaluate_returncode=False) + + # We expect command line check commands to + # + # * have a return code of 0 if an update is available, a value != 0 otherwise + # * return the display name of the new version as the final line on stdout + # * return the display name of the current version as the next to final line on stdout + # + # Example output: + # 1.1.0 + # 1.1.1 + # + # 1.1.0 is the current version, 1.1.1 is the remote version. If only one line is output, it's taken to be the + # display name of the new version + + stdout_lines = filter(lambda x: len(x.strip()), stdout.splitlines()) + local_name = stdout_lines[-2] if len(stdout_lines) >= 2 else "unknown" + remote_name = stdout_lines[-1] if len(stdout_lines) >= 1 else "unknown" + is_current = returncode != 0 + + information =dict( + local=dict( + name=local_name, + value=local_name, + ), + remote=dict( + name=remote_name, + value=remote_name + ) + ) + + logger = logging.getLogger("octoprint.plugins.softwareupdate.version_checks.github_commit") + logger.debug("Target: %s, local: %s, remote: %s" % (target, local_name, remote_name)) + + return information, is_current diff --git a/src/octoprint/plugins/softwareupdate/version_checks/git_commit.py b/src/octoprint/plugins/softwareupdate/version_checks/git_commit.py new file mode 100644 index 00000000..3cb2e0c4 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/version_checks/git_commit.py @@ -0,0 +1,88 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import errno +import subprocess +import sys +import logging + +from ..exceptions import ConfigurationInvalid + +def _get_git_executables(): + GITS = ["git"] + if sys.platform == "win32": + GITS = ["git.cmd", "git.exe"] + return GITS + + +def _git(args, cwd, hide_stderr=False): + commands = _get_git_executables() + + for c in commands: + try: + p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None)) + break + except EnvironmentError: + e = sys.exc_info()[1] + if e.errno == errno.ENOENT: + continue + return None, None + else: + return None, None + + stdout = p.communicate()[0].strip() + if sys.version >= '3': + stdout = stdout.decode() + + if p.returncode != 0: + return p.returncode, None + + return p.returncode, stdout + + +def get_latest(target, check): + if not "checkout_folder" in check: + raise ConfigurationInvalid("Update configuration for %s needs checkout_folder" % target) + + checkout_folder = check["checkout_folder"] + + returncode, _ = _git(["fetch"], checkout_folder) + if returncode != 0: + return None, True + + returncode, local_commit = _git(["rev-parse", "@{0}"], checkout_folder) + if returncode != 0: + return None, True + + returncode, remote_commit = _git(["rev-parse", "@{u}"], checkout_folder) + if returncode != 0: + return None, True + + return_code, base = _git(["merge-base", "@{0}", "@{u}"], checkout_folder) + if returncode != 0: + return None, True + + if local_commit == remote_commit or remote_commit == base: + information = dict( + local=dict(name="Commit %s" % local_commit, value=local_commit), + remote=dict(name="Commit %s" % local_commit, value=local_commit) + ) + is_current = True + else: + information = dict( + local=dict(name="Commit %s" % local_commit, value=local_commit), + remote=dict(name="Commit %s" % remote_commit, value=remote_commit) + ) + is_current = local_commit == remote_commit + + logger = logging.getLogger("octoprint.plugins.softwareupdate.version_checks.git_commit") + logger.debug("Target: %s, local: %s, remote: %s" % (target, local_commit, remote_commit)) + + return information, is_current \ No newline at end of file diff --git a/src/octoprint/plugins/softwareupdate/version_checks/github_commit.py b/src/octoprint/plugins/softwareupdate/version_checks/github_commit.py new file mode 100644 index 00000000..34ca5999 --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/version_checks/github_commit.py @@ -0,0 +1,56 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import requests +import logging + +from ..exceptions import ConfigurationInvalid + +BRANCH_HEAD_URL = "https://api.github.com/repos/{user}/{repo}/git/refs/heads/{branch}" + +logger = logging.getLogger("octoprint.plugins.softwareupdate.version_checks.github_commit") + +def _get_latest_commit(user, repo, branch): + r = requests.get(BRANCH_HEAD_URL.format(user=user, repo=repo, branch=branch)) + + from . import log_github_ratelimit + log_github_ratelimit(logger, r) + + if not r.status_code == requests.codes.ok: + return None + + reference = r.json() + if not "object" in reference or not "sha" in reference["object"]: + return None + + return reference["object"]["sha"] + + +def get_latest(target, check): + if "user" not in check or "repo" not in check: + raise ConfigurationInvalid("Update configuration for %s of type github_commit needs all of user and repo" % target) + + branch = "master" + if "branch" in check: + branch = check["branch"] + + current = None + if "current" in check: + current = check["current"] + + remote_commit = _get_latest_commit(check["user"], check["repo"], branch) + + information = dict( + local=dict(name="Commit {commit}".format(commit=current if current is not None else "unknown"), value=current), + remote=dict(name="Commit {commit}".format(commit=remote_commit if remote_commit is not None else "unknown"), value=remote_commit) + ) + is_current = (current is not None and current == remote_commit) or remote_commit is None + + logger.debug("Target: %s, local: %s, remote: %s" % (target, current, remote_commit)) + + return information, is_current + diff --git a/src/octoprint/plugins/softwareupdate/version_checks/github_release.py b/src/octoprint/plugins/softwareupdate/version_checks/github_release.py new file mode 100644 index 00000000..94823eef --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/version_checks/github_release.py @@ -0,0 +1,91 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import requests +import logging + +from ..exceptions import ConfigurationInvalid + +RELEASE_URL = "https://api.github.com/repos/{user}/{repo}/releases" + +logger = logging.getLogger("octoprint.plugins.softwareupdate.version_checks.github_release") + +def _get_latest_release(user, repo, include_prerelease=False): + r = requests.get(RELEASE_URL.format(user=user, repo=repo)) + + from . import log_github_ratelimit + log_github_ratelimit(logger, r) + + if not r.status_code == requests.codes.ok: + return None, None + + releases = r.json() + + # filter out prereleases and drafts + if include_prerelease: + releases = filter(lambda rel: not rel["draft"], releases) + else: + releases = filter(lambda rel: not rel["prerelease"] and not rel["draft"], releases) + + if not releases: + return None, None + + # sort by date + comp = lambda a, b: cmp(a["published_at"], b["published_at"]) + releases = sorted(releases, cmp=comp) + + # latest release = last in list + latest = releases[-1] + + return latest["name"], latest["tag_name"] + + +def _is_current(release_information, compare_type, custom=None): + if release_information["remote"]["value"] is None: + return True + + if not compare_type in ("semantic", "unequal", "custom") or compare_type == "custom" and custom is None: + compare_type = "semantic" + + try: + if compare_type == "semantic": + import semantic_version + + local_version = semantic_version.Version(release_information["local"]["value"]) + remote_version = semantic_version.Version(release_information["remote"]["value"]) + + return local_version >= remote_version + + elif compare_type == "custom": + return custom(release_information["local"], release_information["remote"]) + + else: + return release_information["local"]["value"] == release_information["remote"]["value"] + except: + logger.exception("Could not check if version is current due to an error, assuming it is") + return True + + +def get_latest(target, check, custom_compare=None): + if not "user" in check or not "repo" in check: + raise ConfigurationInvalid("github_release update configuration for %s needs user and repo set" % target) + + current = None + if "current" in check: + current = check["current"] + + remote_name, remote_tag = _get_latest_release(check["user"], check["repo"], include_prerelease=check["prerelease"] == True if "prerelease" in check else False) + compare_type = check["release_compare"] if "release_compare" in check else "semantic" + + information =dict( + local=dict(name=current, value=current), + remote=dict(name=remote_name, value=remote_tag) + ) + + logger.debug("Target: %s, local: %s, remote: %s" % (target, current, remote_tag)) + + return information, _is_current(information, compare_type, custom=custom_compare) diff --git a/src/octoprint/plugins/softwareupdate/version_checks/python_checker.py b/src/octoprint/plugins/softwareupdate/version_checks/python_checker.py new file mode 100644 index 00000000..3ca017ab --- /dev/null +++ b/src/octoprint/plugins/softwareupdate/version_checks/python_checker.py @@ -0,0 +1,14 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +from ..exceptions import ConfigurationInvalid + +def get_latest(target, check, full_data=False): + if not "python_checker" in check: + raise ConfigurationInvalid("Update configuration for %s of type commandline needs command defined" % target) + + return check["python_checker"].get_latest(target, check, full_data=full_data)