Bundled Software Update Plugin
This commit is contained in:
parent
cd390a53af
commit
2e6240a910
18 changed files with 1690 additions and 0 deletions
462
src/octoprint/plugins/softwareupdate/__init__.py
Normal file
462
src/octoprint/plugins/softwareupdate/__init__.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
)
|
||||
|
||||
|
||||
37
src/octoprint/plugins/softwareupdate/exceptions.py
Normal file
37
src/octoprint/plugins/softwareupdate/exceptions.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
|
||||
180
src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py
Normal file
180
src/octoprint/plugins/softwareupdate/scripts/update-octoprint.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
#!/bin/env python
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Haeussge <osd@foosel.net>"
|
||||
__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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
td.settings_plugin_softwareupdate_column_update{width:16px}
|
||||
435
src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js
Normal file
435
src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js
Normal file
|
|
@ -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 += "<ul>";
|
||||
_.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 += "<li>" + displayName + (update_info.updatePossible ? " <i class=\"icon-ok\"></i>" : "") + "</li>";
|
||||
}
|
||||
});
|
||||
text += "</ul>";
|
||||
|
||||
text += "<small>" + gettext("Those components marked with <i class=\"icon-ok\"></i> can be updated directly.") + "</small>";
|
||||
|
||||
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")]);
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
td.settings_plugin_softwareupdate_column_update {
|
||||
width: 16px;
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<div class="pull-right">
|
||||
<button class="btn btn-small" data-bind="click: function() { $root.showPluginSettings(); }" title="{{ _('Plugin Configuration') }}"><i class="icon-wrench"></i></button>
|
||||
</div>
|
||||
|
||||
<h3>{{ _('Current versions') }}</h3>
|
||||
|
||||
<table class="table table-striped table-hover table-condensed table-hover">
|
||||
<tbody data-bind="foreach: versions.paginatedItems">
|
||||
<tr data-bind="attr: {title: displayName}">
|
||||
<td class="settings_plugin_softwareupdate_column_update">
|
||||
<span data-bind="invisible: !updateAvailable"><i class="icon-bell" title="{{ _('Update available') }}"></i></span>
|
||||
</td>
|
||||
<td class="settings_plugin_softwareupdate_column_information">
|
||||
<strong data-bind="text: displayName"></strong>: <span data-bind="text: displayVersion"></span><br>
|
||||
<small class="muted">
|
||||
{{ _('Local:') }} <span data-bind="text: information.local.name"></span><br>
|
||||
{{ _('Remote:') }} <span data-bind="text: information.remote.name"></span>
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination pagination-mini pagination-centered">
|
||||
<ul>
|
||||
<li data-bind="css: {disabled: versions.currentPage() === 0}">
|
||||
<a href="#" data-bind="click: versions.prevPage">«</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul data-bind="foreach: versions.pages">
|
||||
<li data-bind="css: { active: $data.number === $root.versions.currentPage(), disabled: $data.number === -1 }">
|
||||
<a href="#" data-bind="text: $data.text, click: function() { $root.versions.changePage($data.number); }"></a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li data-bind="css: {disabled: versions.currentPage() === versions.lastPage()}">
|
||||
<a href="#" data-bind="click: versions.nextPage">»</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" data-bind="click: function() { $root.performCheck(true, false, true); }">{{ _('Check for update now') }}</button>
|
||||
|
||||
<div>
|
||||
<div><small><a href="#" class="muted" onclick="$(this).children().toggleClass('icon-caret-right icon-caret-down').parent().parent().parent().next().slideToggle('fast')"><i class="icon-caret-right"></i> {{ _('Advanced options') }}</a></small></div>
|
||||
<div class="hide">
|
||||
<button class="btn btn-block" data-bind="click: function() { $root.performCheck(true, true, true); }">{{ _('Force check for update (overrides cache used for update checks)') }}</button>
|
||||
<button class="btn btn-block btn-danger" data-bind="click: function() { $root.update(true); }">{{ _('Force update now (even if no new versions are available)') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings_plugin_softwareupdate_configurationdialog" class="modal hide fade">
|
||||
<div class="modal-header">
|
||||
<a href="#" class="close" data-dismiss="modal" aria-hidden="true">×</a>
|
||||
<h3>{{ _('Plugin Configuration') }}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal">
|
||||
<div class="control-group">
|
||||
<label class="control-label">{{ _('Restart Command') }}</label>
|
||||
<div class="controls">
|
||||
<input type="text" class="input-block-level" data-bind="value: config_restartCommand">
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">{{ _('Reboot Command') }}</label>
|
||||
<div class="controls">
|
||||
<input type="text" class="input-block-level" data-bind="value: config_rebootCommand">
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">{{ _('Version cache TTL') }}</label>
|
||||
<div class="controls">
|
||||
<div class="input-append">
|
||||
<input type="number" class="input-mini" data-bind="value: config_cacheTtl">
|
||||
<span class="add-on">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" data-dismiss="modal" aria-hidden="true">{{ _('Cancel') }}</button>
|
||||
<button class="btn btn-primary" data-bind="click: savePluginSettings" aria-hidden="true">{{ _('Save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
37
src/octoprint/plugins/softwareupdate/updaters/pip.py
Normal file
37
src/octoprint/plugins/softwareupdate/updaters/pip.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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"
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
27
src/octoprint/plugins/softwareupdate/util.py
Normal file
27
src/octoprint/plugins/softwareupdate/util.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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))
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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
|
||||
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Gina Häußge <osd@foosel.net>"
|
||||
__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)
|
||||
Loading…
Reference in a new issue