MrDraw/src/octoprint/plugins/softwareupdate/__init__.py
Gina Häußge 9fac314d71 SWU: restart asynchronously instead of synchronously
Otherwise we will block ourselves, waiting for the restart command to
complete which it only can when we are no longer there.

Should reduce restart times on update significantly.

Downside is that we no longer can wait for the return code of the
call. However, that should be caught by our UI handler timing out for
the restart and showing an error prompting the user to restart
manually.
2017-05-17 13:44:11 +02:00

1078 lines
41 KiB
Python

# coding=utf-8
from __future__ import absolute_import, division, print_function
__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
import logging
import logging.handlers
import hashlib
from . import version_checks, updaters, exceptions, util, cli
from flask.ext.babel import gettext
from octoprint.server.util.flask import restricted_access, with_revalidation_checking, check_etag
from octoprint.server import admin_permission, VERSION, REVISION, BRANCH
from octoprint.util import dict_merge, to_unicode
import octoprint.settings
##~~ Plugin
class SoftwareUpdatePlugin(octoprint.plugin.BlueprintPlugin,
octoprint.plugin.SettingsPlugin,
octoprint.plugin.AssetPlugin,
octoprint.plugin.TemplatePlugin,
octoprint.plugin.StartupPlugin,
octoprint.plugin.WizardPlugin):
COMMIT_TRACKING_TYPES = ("github_commit", "bitbucket_commit")
def __init__(self):
self._update_in_progress = False
self._configured_checks_mutex = threading.Lock()
self._configured_checks = None
self._refresh_configured_checks = False
self._get_versions_mutex = threading.Lock()
self._version_cache = dict()
self._version_cache_ttl = 0
self._version_cache_path = None
self._version_cache_dirty = False
self._version_cache_timestamp = None
self._console_logger = None
def initialize(self):
self._console_logger = logging.getLogger("octoprint.plugins.softwareupdate.console")
self._version_cache_ttl = self._settings.get_int(["cache_ttl"]) * 60
self._version_cache_path = os.path.join(self.get_plugin_data_folder(), "versioncache.yaml")
self._load_version_cache()
def refresh_checks(name, plugin):
self._refresh_configured_checks = True
self._send_client_message("update_versions")
self._plugin_lifecycle_manager.add_callback("enabled", refresh_checks)
self._plugin_lifecycle_manager.add_callback("disabled", refresh_checks)
def on_startup(self, host, port):
console_logging_handler = logging.handlers.RotatingFileHandler(self._settings.get_plugin_logfile_path(postfix="console"), maxBytes=2*1024*1024)
console_logging_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
console_logging_handler.setLevel(logging.DEBUG)
self._console_logger.addHandler(console_logging_handler)
self._console_logger.setLevel(logging.DEBUG)
self._console_logger.propagate = False
def on_after_startup(self):
# refresh cache now if necessary so it's faster once the user connects to the instance - but decouple it from
# the server startup
def fetch_data():
self.get_current_versions()
thread = threading.Thread(target=fetch_data)
thread.daemon = True
thread.start()
def _get_configured_checks(self):
with self._configured_checks_mutex:
if self._refresh_configured_checks or self._configured_checks is None:
self._refresh_configured_checks = False
self._configured_checks = self._settings.get(["checks"], merged=True)
update_check_hooks = self._plugin_manager.get_hooks("octoprint.plugin.softwareupdate.check_config")
check_providers = self._settings.get(["check_providers"], merged=True)
effective_configs = dict()
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, default_config in hook_checks.items():
if key in effective_configs or key == "octoprint":
if key == name:
self._logger.warn("Software update hook {} provides check for itself but that was already registered by {} - overwriting that third party registration now!".format(name, check_providers.get(key, "unknown hook")))
else:
self._logger.warn("Software update hook {} tried to overwrite config for check {} but that was already configured elsewhere".format(name, key))
continue
check_providers[key] = name
yaml_config = dict()
effective_config = default_config
if key in self._configured_checks:
yaml_config = self._configured_checks[key]
effective_config = dict_merge(default_config, yaml_config)
# Make sure there's nothing persisted in that check that shouldn't be persisted
#
# This used to be part of the settings migration (version 2) due to a bug - it can't
# stay there though since it interferes with manual entries to the checks not
# originating from within a plugin. Hence we do that step now here.
if "type" not in effective_config or effective_config["type"] not in self.COMMIT_TRACKING_TYPES:
deletables = ["current", "displayVersion"]
else:
deletables = []
self._clean_settings_check(key, yaml_config, default_config, delete=deletables, save=False)
if effective_config:
effective_configs[key] = effective_config
else:
self._logger.warn("Update for {} is empty or None, ignoring it".format(key))
# finally set all our internal representations to our processed results
for key, config in effective_configs.items():
self._configured_checks[key] = config
self._settings.set(["check_providers"], check_providers)
self._settings.save()
# we only want to process checks that came from plugins for
# which the plugins are still installed and enabled
config_checks = self._settings.get(["checks"])
plugin_and_not_enabled = lambda k: k in check_providers and \
not check_providers[k] in self._plugin_manager.enabled_plugins
obsolete_plugin_checks = filter(plugin_and_not_enabled,
config_checks.keys())
for key in obsolete_plugin_checks:
self._logger.debug("Check for key {} was provided by plugin {} that's no longer available, ignoring it".format(key, check_providers[key]))
del self._configured_checks[key]
return self._configured_checks
def _load_version_cache(self):
if not os.path.isfile(self._version_cache_path):
return
import yaml
try:
with open(self._version_cache_path) as f:
data = yaml.safe_load(f)
timestamp = os.stat(self._version_cache_path).st_mtime
except:
self._logger.exception("Error while loading version cache from disk")
else:
try:
if not isinstance(data, dict):
self._logger.info("Version cache was created in a different format, not using it")
return
if "__version" in data:
data_version = data["__version"]
else:
self._logger.info("Can't determine version of OctoPrint version cache was created for, not using it")
return
from octoprint._version import get_versions
octoprint_version = get_versions()["version"]
if data_version != octoprint_version:
self._logger.info("Version cache was created for another version of OctoPrint, not using it")
return
self._version_cache = data
self._version_cache_dirty = False
self._version_cache_timestamp = timestamp
self._logger.info("Loaded version cache from disk")
except:
self._logger.exception("Error parsing in version cache data")
def _save_version_cache(self):
import yaml
from octoprint.util import atomic_write
from octoprint._version import get_versions
octoprint_version = get_versions()["version"]
self._version_cache["__version"] = octoprint_version
with atomic_write(self._version_cache_path, max_permissions=0o666) as file_obj:
yaml.safe_dump(self._version_cache, stream=file_obj, default_flow_style=False, indent=" ", allow_unicode=True)
self._version_cache_dirty = False
self._version_cache_timestamp = time.time()
self._logger.info("Saved version cache to disk")
#~~ SettingsPlugin API
def get_settings_defaults(self):
update_script = os.path.join(self._basefolder, "scripts", "update-octoprint.py")
return {
"checks": {
"octoprint": {
"type": "github_release",
"user": "foosel",
"repo": "OctoPrint",
"update_script": "{{python}} \"{update_script}\" --branch={{branch}} --force={{force}} \"{{folder}}\" {{target}}".format(update_script=update_script),
"restart": "octoprint",
"stable_branch": dict(branch="master", name="Stable"),
"prerelease_branches": [dict(branch="rc/maintenance", name="Maintenance RCs"),
dict(branch="rc/devel", name="Devel RCs")]
},
},
"pip_command": None,
"check_providers": {},
"cache_ttl": 24 * 60,
"notify_users": True
}
def on_settings_load(self):
data = dict(octoprint.plugin.SettingsPlugin.on_settings_load(self))
if "checks" in data:
del data["checks"]
if "check_providers" in data:
del data["check_providers"]
checks = self._get_configured_checks()
if "octoprint" in checks:
data["octoprint_checkout_folder"] = self._get_octoprint_checkout_folder(checks=checks)
data["octoprint_type"] = checks["octoprint"].get("type", None)
try:
data["octoprint_method"] = self._get_update_method("octoprint", checks["octoprint"])
except exceptions.UnknownUpdateType:
data["octoprint_method"] = "unknown"
stable_branch = None
prerelease_branches = []
branch_mappings = []
if "stable_branch" in checks["octoprint"]:
branch_mappings.append(checks["octoprint"]["stable_branch"])
stable_branch = checks["octoprint"]["stable_branch"]["branch"]
if "prerelease_branches" in checks["octoprint"]:
for mapping in checks["octoprint"]["prerelease_branches"]:
branch_mappings.append(mapping)
prerelease_branches.append(mapping["branch"])
data["octoprint_branch_mappings"] = branch_mappings
data["octoprint_release_channel"] = stable_branch
if checks["octoprint"].get("prerelease", False):
channel = checks["octoprint"].get("prerelease_channel", BRANCH)
if channel in prerelease_branches:
data["octoprint_release_channel"] = channel
else:
data["octoprint_checkout_folder"] = None
data["octoprint_type"] = None
data["octoprint_branch_mappings"] = []
return data
def on_settings_save(self, data):
for key in self.get_settings_defaults():
if key in ("checks", "cache_ttl", "notify_user", "octoprint_checkout_folder", "octoprint_type", "octoprint_release_channel"):
continue
if key in data:
self._settings.set([key], data[key])
if "cache_ttl" in data:
self._settings.set_int(["cache_ttl"], data["cache_ttl"])
self._version_cache_ttl = self._settings.get_int(["cache_ttl"]) * 60
if "notify_users" in data:
self._settings.set_boolean(["notify_users"], data["notify_users"])
checks = self._get_configured_checks()
if "octoprint" in checks:
check = checks["octoprint"]
update_type = check.get("type", None)
checkout_folder = check.get("checkout_folder", None)
update_folder = check.get("update_folder", None)
prerelease = check.get("prerelease", False)
prerelease_channel = check.get("prerelease_channel", None)
else:
update_type = checkout_folder = update_folder = prerelease_channel = None
prerelease = False
defaults = dict(
plugins=dict(softwareupdate=dict(
checks=dict(
octoprint=dict(
type=update_type,
checkout_folder=checkout_folder,
update_folder=update_folder,
prerelease=prerelease,
prerelease_channel=prerelease_channel
)
)
))
)
updated_octoprint_check_config = False
if "octoprint_checkout_folder" in data:
self._settings.set(["checks", "octoprint", "checkout_folder"], data["octoprint_checkout_folder"], defaults=defaults, force=True)
if update_folder and data["octoprint_checkout_folder"]:
self._settings.set(["checks", "octoprint", "update_folder"], None, defaults=defaults, force=True)
updated_octoprint_check_config = True
if "octoprint_type" in data and data["octoprint_type"] in ("github_release", "git_commit"):
self._settings.set(["checks", "octoprint", "type"], data["octoprint_type"], defaults=defaults, force=True)
updated_octoprint_check_config = True
if updated_octoprint_check_config:
self._refresh_configured_checks = True
try:
del self._version_cache["octoprint"]
except KeyError:
pass
self._version_cache_dirty = True
if "octoprint_release_channel" in data:
prerelease_branches = self._settings.get(["checks", "octoprint", "prerelease_branches"])
if prerelease_branches and data["octoprint_release_channel"] in [x["branch"] for x in prerelease_branches]:
self._settings.set(["checks", "octoprint", "prerelease"], True, defaults=defaults, force=True)
self._settings.set(["checks", "octoprint", "prerelease_channel"], data["octoprint_release_channel"], defaults=defaults, force=True)
self._refresh_configured_checks = True
else:
self._settings.set(["checks", "octoprint", "prerelease"], False, defaults=defaults, force=True)
self._settings.set(["checks", "octoprint", "prerelease_channel"], None, defaults=defaults, force=True)
self._refresh_configured_checks = True
def get_settings_version(self):
return 5
def on_settings_migrate(self, target, current=None):
if current == 4:
# config version 4 didn't correctly remove the old settings for octoprint_restart_command
# and environment_restart_command
self._settings.set(["environment_restart_command"], None)
self._settings.set(["octoprint_restart_command"], None)
if current is None or current < 5:
# config version 4 and higher moves octoprint_restart_command and
# environment_restart_command to the core configuration
# current plugin commands
configured_octoprint_restart_command = self._settings.get(["octoprint_restart_command"])
configured_environment_restart_command = self._settings.get(["environment_restart_command"])
# current global commands
configured_system_restart_command = self._settings.global_get(["server", "commands", "systemRestartCommand"])
configured_server_restart_command = self._settings.global_get(["server", "commands", "serverRestartCommand"])
# only set global commands if they are not yet set
if configured_system_restart_command is None and configured_environment_restart_command is not None:
self._settings.global_set(["server", "commands", "systemRestartCommand"], configured_environment_restart_command)
if configured_server_restart_command is None and configured_octoprint_restart_command is not None:
self._settings.global_set(["server", "commands", "serverRestartCommand"], configured_octoprint_restart_command)
# delete current plugin commands from config
self._settings.set(["environment_restart_command"], None)
self._settings.set(["octoprint_restart_command"], None)
if current is None or current == 2:
# No config version and config version 2 need the same fix, stripping
# accidentally persisted data off the checks.
#
# We used to do the same processing for the plugin entries too here, but that interfered
# with manual configuration entries. Stuff got deleted that wasn't supposed to be deleted.
#
# The problem is that we don't know if an entry we are looking at and which didn't come through
# a plugin hook is simply an entry from a now uninstalled/unactive plugin, or if it was something
# manually configured by the user. So instead of just blindly removing anything that doesn't
# come from a plugin here we instead clean up anything that indeed comes from a plugin
# during run time and leave everything else as is in the hopes that will not cause trouble.
#
# We still handle the "octoprint" entry here though.
configured_checks = self._settings.get(["checks"], incl_defaults=False)
if configured_checks is not None and "octoprint" in configured_checks:
octoprint_check = dict(configured_checks["octoprint"])
if "type" not in octoprint_check or octoprint_check["type"] not in self.COMMIT_TRACKING_TYPES:
deletables=["current", "displayName", "displayVersion"]
else:
deletables=[]
self._clean_settings_check("octoprint", octoprint_check, self.get_settings_defaults()["checks"]["octoprint"], delete=deletables, save=False)
elif current == 1:
# config version 1 had the error that the octoprint check got accidentally
# included in checks["octoprint"], leading to recursion and hence to
# yaml parser errors
configured_checks = self._settings.get(["checks"], incl_defaults=False)
if configured_checks is None:
return
if "octoprint" in configured_checks and "octoprint" in configured_checks["octoprint"]:
# that's a circular reference, back to defaults
dummy_defaults = dict(plugins=dict())
dummy_defaults["plugins"][self._identifier] = dict(checks=dict())
dummy_defaults["plugins"][self._identifier]["checks"]["octoprint"] = None
self._settings.set(["checks", "octoprint"], None, defaults=dummy_defaults)
def _clean_settings_check(self, key, data, defaults, delete=None, save=True):
if not data:
# nothing to do
return data
if delete is None:
delete = []
for k, v in data.items():
if k in defaults and defaults[k] == data[k]:
del data[k]
for k in delete:
if k in data:
del data[k]
dummy_defaults = dict(plugins=dict())
dummy_defaults["plugins"][self._identifier] = dict(checks=dict())
dummy_defaults["plugins"][self._identifier]["checks"][key] = defaults
if len(data):
self._settings.set(["checks", key], data, defaults=dummy_defaults)
else:
self._settings.set(["checks", key], None, defaults=dummy_defaults)
if save:
self._settings.save()
return data
#~~ BluePrint API
@octoprint.plugin.BlueprintPlugin.route("/check", methods=["GET"])
@restricted_access
def check_for_update(self):
if "check" in flask.request.values:
check_targets = map(lambda x: x.strip(), flask.request.values["check"].split(","))
else:
check_targets = None
force = flask.request.values.get("force", "false") in octoprint.settings.valid_boolean_trues
def view():
try:
information, update_available, update_possible = self.get_current_versions(check_targets=check_targets, force=force)
# we don't want to transfer python_checker or python_updater values through json - replace with True
for key, data in information.items():
if "check" in data:
if "python_checker" in data["check"]:
data["check"]["python_checker"] = True
if "python_updater" in data["check"]:
data["check"]["python_updater"] = True
return flask.jsonify(dict(status="updatePossible" if update_available and update_possible else "updateAvailable" if update_available else "current",
information=information,
timestamp=self._version_cache_timestamp))
except exceptions.ConfigurationInvalid as e:
return flask.make_response("Update not properly configured, can't proceed: %s" % e.message, 500)
def etag():
checks = self._get_configured_checks()
targets = check_targets
if targets is None:
targets = checks.keys()
import hashlib
hash = hashlib.sha1()
targets = sorted(targets)
for target in targets:
current_hash = self._get_check_hash(checks.get(target, dict()))
if target in self._version_cache and not force:
data = self._version_cache[target]
hash.update(current_hash)
hash.update(str(data["timestamp"] + self._version_cache_ttl >= time.time() > data["timestamp"]))
hash.update(repr(data["information"]))
hash.update(str(data["available"]))
hash.update(str(data["possible"]))
hash.update(",".join(targets))
hash.update(str(self._version_cache_timestamp))
return hash.hexdigest()
def condition():
return check_etag(etag())
return with_revalidation_checking(etag_factory=lambda *args, **kwargs: etag(),
condition=lambda *args, **kwargs: condition(),
unless=lambda: force)(view)()
@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
return flask.make_response("Printer is currently printing or paused", 409)
if not "application/json" in flask.request.headers["Content-Type"]:
return flask.make_response("Expected content-type JSON", 400)
json_data = flask.request.json
if "check" in json_data:
check_targets = map(lambda x: x.strip(), json_data["check"])
else:
check_targets = None
if "force" in json_data and json_data["force"] in octoprint.settings.valid_boolean_trues:
force = True
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):
from flask.ext.babel import gettext
return [
dict(type="settings", name=gettext("Software Update"))
]
##~~ WizardPlugin API
def is_wizard_required(self):
checks = self._get_configured_checks()
check = checks.get("octoprint", None)
checkout_folder = self._get_octoprint_checkout_folder(checks=checks)
return check and "update_script" in check and not checkout_folder
#~~ 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()
# we don't want to do the same work twice, so let's use a lock
with self._get_versions_mutex:
for target, check in checks.items():
if not target in check_targets:
continue
if not check:
continue
try:
populated_check = self._populated_check(target, check)
target_information, target_update_available, target_update_possible = self._get_current_version(target, populated_check, force=force)
if target_information is None:
target_information = dict()
except exceptions.UnknownCheckType:
self._logger.warn("Unknown update check type for target {}: {}".format(target, check.get("type", "<n/a>")))
continue
target_information = dict_merge(dict(local=dict(name="unknown", value="unknown"), remote=dict(name="unknown", value="unknown", release_notes=None)), target_information)
update_available = update_available or target_update_available
update_possible = update_possible or (target_update_possible and target_update_available)
local_name = target_information["local"]["name"]
local_value = target_information["local"]["value"]
release_notes = None
if target_information and target_information["remote"] and target_information["remote"]["value"]:
if "release_notes" in populated_check and populated_check["release_notes"]:
release_notes = populated_check["release_notes"]
elif "release_notes" in target_information["remote"]:
release_notes = target_information["remote"]["release_notes"]
if release_notes:
release_notes = release_notes.format(octoprint_version=VERSION,
target_name=target_information["remote"]["name"],
target_version=target_information["remote"]["value"])
information[target] = dict(updateAvailable=target_update_available,
updatePossible=target_update_possible,
information=target_information,
displayName=populated_check["displayName"],
displayVersion=populated_check["displayVersion"].format(octoprint_version=VERSION, local_name=local_name, local_value=local_value),
check=populated_check,
releaseNotes=release_notes)
if self._version_cache_dirty:
self._save_version_cache()
return information, update_available, update_possible
def _get_check_hash(self, check):
def dict_to_sorted_repr(d):
lines = []
for key in sorted(d.keys()):
value = d[key]
if isinstance(value, dict):
lines.append("{!r}: {}".format(key, dict_to_sorted_repr(value)))
else:
lines.append("{!r}: {!r}".format(key, value))
return "{" + ", ".join(lines) + "}"
hash = hashlib.md5()
hash.update(dict_to_sorted_repr(check))
return hash.hexdigest()
def _get_current_version(self, target, check, force=False):
"""
Determines the current version information for one target based on its check configuration.
"""
current_hash = self._get_check_hash(check)
if target in self._version_cache and not force:
data = self._version_cache[target]
if data["hash"] == current_hash and data["timestamp"] + self._version_cache_ttl >= time.time() > data["timestamp"]:
# we also check that timestamp < now to not get confused too much by clock changes
return data["information"], data["available"], data["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] = dict(timestamp=time.time(),
hash=current_hash,
information=information,
available=update_available,
possible=update_possible)
self._version_cache_dirty = True
return information, update_available, update_possible
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()
populated_checks = dict()
for target, check in checks.items():
try:
populated_checks[target] = self._populated_check(target, check)
except exceptions.UnknownCheckType:
self._logger.debug("Ignoring unknown check type for target {}".format(target))
except:
self._logger.exception("Error while populating check prior to update for target {}".format(target))
if check_targets is None:
check_targets = populated_checks.keys()
to_be_updated = sorted(set(check_targets) & set(populated_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=(populated_checks, to_be_updated, force))
updater_thread.daemon = False
updater_thread.start()
check_data = dict((key, check["displayName"] if "displayName" in check else key) for key, check in populated_checks.items() if key in to_be_updated)
return to_be_updated, check_data
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"
else:
target_restart_type = None
# 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:
self._save_version_cache()
# 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 = None
if restart_type == "octoprint":
restart_command = self._settings.global_get(["server", "commands", "serverRestartCommand"])
elif restart_type == "environment":
restart_command = self._settings.global_get(["server", "commands", "systemRestartCommand"])
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...
populated_check = self._populated_check(target, check)
try:
self._logger.info("Starting update of %s to %s..." % (target, target_version))
self._send_client_message("updating", dict(target=target, version=target_version, name=populated_check["displayName"]))
updater = self._get_updater(target, check)
if updater is None:
raise exceptions.UnknownUpdateType()
update_result = updater.perform_update(target, populated_check, target_version, log_cb=self._log)
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, name=populated_check["displayName"], 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 populated_check or not populated_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, name=populated_check["displayName"], reason=e.data))
else:
target_result = ("failed", None)
self._send_client_message("update_failed", dict(target=target, version=target_version, name=populated_check["displayName"], 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"] in self.COMMIT_TRACKING_TYPES:
dummy_default = dict(plugins=dict())
dummy_default["plugins"][self._identifier] = dict(checks=dict())
dummy_default["plugins"][self._identifier]["checks"][target] = dict(current=None)
self._settings.set(["checks", target, "current"], target_version, defaults=dummy_default)
# 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()
del self._version_cache[target]
self._version_cache_dirty = True
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, evaluate_returncode=False, async=True)
except exceptions.ScriptError as e:
self._logger.exception("Error while restarting via command {}".format(restart_command))
self._logger.warn("Restart stdout:\n{}".format(e.stdout))
self._logger.warn("Restart stderr:\n{}".format(e.stderr))
raise exceptions.RestartFailed()
def _populated_check(self, target, check):
if not "type" in check:
raise exceptions.UnknownCheckType()
result = dict(check)
if target == "octoprint":
from flask.ext.babel import gettext
result["displayName"] = to_unicode(check.get("displayName"), errors="replace")
if result["displayName"] is None:
# displayName missing or set to None
result["displayName"] = to_unicode(gettext("OctoPrint"), errors="replace")
result["displayVersion"] = to_unicode(check.get("displayVersion"), errors="replace")
if result["displayVersion"] is None:
# displayVersion missing or set to None
result["displayVersion"] = u"{octoprint_version}"
stable_branch = "master"
release_branches = []
if "stable_branch" in check:
release_branches.append(check["stable_branch"]["branch"])
stable_branch = check["stable_branch"]["branch"]
if "prerelease_branches" in check:
release_branches += [x["branch"] for x in check["prerelease_branches"]]
result["released_version"] = not release_branches or BRANCH in release_branches
if check["type"] in self.COMMIT_TRACKING_TYPES:
result["current"] = REVISION if REVISION else "unknown"
else:
result["current"] = VERSION
if check["type"] == "github_release" and (check.get("prerelease", None) or BRANCH != stable_branch):
# we are tracking github releases and are either also tracking prerelease OR are currently installed
# from something that is not the stable (master) branch => we need to change some parameters
# we compare versions fully, not just the base so that we see a difference
# between RCs + stable for the same version release
result["force_base"] = False
if check.get("update_script", None):
# if we are using the update_script, we need to set our update_branch and force
# to install the exact version we requested
if check.get("prerelease", None):
# we are tracking prereleases => we want to be on the correct prerelease channel/branch
channel = check.get("prerelease_channel", None)
if channel:
# if we have a release channel, we also set our update_branch here to our release channel
# in case it's not already set
result["update_branch"] = check.get("update_branch", channel)
# we also force our target version in the update
result["force_exact_version"] = True
else:
# we are not tracking prereleases, but aren't on the stable branch either => switch back
# to stable branch on update
result["update_branch"] = check.get("update_branch", stable_branch)
if BRANCH != result.get("prerelease_channel"):
# we force python unequality check here because that will also allow us to
# downgrade on a prerelease channel change (rc/devel => rc/maintenance)
#
# we detect channel changes by comparing the current branch with the target
# branch of the release channel - unequality means we might have to handle
# a downgrade
result["release_compare"] = "python_unequal"
else:
result["displayName"] = to_unicode(check.get("displayName"), errors="replace")
if result["displayName"] is None:
# displayName missing or None
result["displayName"] = to_unicode(target, errors="replace")
result["displayVersion"] = to_unicode(check.get("displayVersion", check.get("current")), errors="replace")
if result["displayVersion"] is None:
# displayVersion AND current missing or None
result["displayVersion"] = u"unknown"
if check["type"] in self.COMMIT_TRACKING_TYPES:
result["current"] = check.get("current", None)
else:
result["current"] = check.get("current", check.get("displayVersion", None))
if "pip" in result:
if not "pip_command" in check and self._settings.get(["pip_command"]) is not None:
result["pip_command"] = self._settings.get(["pip_command"])
return result
def _log(self, lines, prefix=None, stream=None, strip=True):
if strip:
lines = map(lambda x: x.strip(), lines)
self._send_client_message("loglines", data=dict(loglines=[dict(line=line, stream=stream) for line in lines]))
for line in lines:
self._console_logger.debug(u"{} {}".format(prefix, line))
def _send_client_message(self, message_type, data=None):
self._plugin_manager.send_plugin_message(self._identifier, dict(type=message_type, data=data))
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")
check_type = check["type"]
method = getattr(version_checks, check_type)
if method is None:
raise exceptions.UnknownCheckType()
else:
return method
def _get_update_method(self, target, check, valid_methods=None):
"""
Determines the update method for the given target and check.
If ``valid_methods`` is provided, determine method must be contained
therein to be considered valid.
Raises an ``UnknownUpdateType`` exception if method cannot be determined
or validated.
"""
method = None
if "method" in check:
method = check["method"]
else:
if "update_script" in check:
method = "update_script"
elif "pip" in check:
method = "pip"
elif "python_updater" in check:
method = "python_updater"
if method is None or (valid_methods and not method in valid_methods):
raise exceptions.UnknownUpdateType()
return method
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.
"""
mapping = dict(update_script=updaters.update_script,
pip=updaters.pip,
python_updater=updaters.python_updater)
method = self._get_update_method(target, check, valid_methods=mapping.keys())
return mapping[method]
def _get_octoprint_checkout_folder(self, checks=None):
if checks is None:
checks = self._get_configured_checks()
if not "octoprint" in checks:
return None
if "checkout_folder" in checks["octoprint"]:
return checks["octoprint"]["checkout_folder"]
elif "update_folder" in checks["octoprint"]:
return checks["octoprint"]["update_folder"]
return None
__plugin_name__ = "Software Update"
__plugin_author__ = "Gina Häußge"
__plugin_url__ = "http://docs.octoprint.org/en/master/bundledplugins/softwareupdate.html"
__plugin_description__ = "Allows receiving update notifications and performing updates of OctoPrint and plugins"
__plugin_disabling_discouraged__ = gettext("Without this plugin OctoPrint will no longer be able to "
"update itself or any of your installed plugins which might put "
"your system at risk.")
__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
)
global __plugin_hooks__
__plugin_hooks__ = {
"octoprint.cli.commands": cli.commands
}