Charmed-Kubernetes/kubernetes-control-plane/lib/charms/layer/snap.py

493 lines
16 KiB
Python

# Copyright 2016-2019 Canonical Ltd.
#
# This file is part of the Snap layer for Juju.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import subprocess
import tenacity
import yaml
from charmhelpers.core import hookenv
from charms import layer
from charms import reactive
from charms.reactive.helpers import any_file_changed, data_changed
from datetime import datetime, timedelta
def get_installed_flag(snapname):
return "snap.installed.{}".format(snapname)
def get_refresh_available_flag(snapname):
return "snap.refresh-available.{}".format(snapname)
def get_local_flag(snapname):
return "snap.local.{}".format(snapname)
def get_disabled_flag(snapname):
return "snap.disabled.{}".format(snapname)
def install(snapname, **kw):
"""Install a snap.
Snap will be installed from the coresponding resource if available,
otherwise from the Snap Store.
Sets the snap.installed.{snapname} flag.
If the snap.installed.{snapname} flag is already set then the refresh()
function is called.
"""
installed_flag = get_installed_flag(snapname)
local_flag = get_local_flag(snapname)
if reactive.is_flag_set(installed_flag):
refresh(snapname, **kw)
else:
if hookenv.has_juju_version("2.0"):
res_path = _resource_get(snapname)
if res_path is False:
_install_store(snapname, **kw)
else:
_install_local(res_path, **kw)
reactive.set_flag(local_flag)
else:
_install_store(snapname, **kw)
reactive.set_flag(installed_flag)
# Installing any snap will first ensure that 'core' is installed. Set an
# appropriate flag for consumers that want to get/set core options.
core_installed = get_installed_flag("core")
if not reactive.is_flag_set(core_installed):
reactive.set_flag(core_installed)
def is_installed(snapname):
return reactive.is_flag_set(get_installed_flag(snapname))
def is_local(snapname):
return reactive.is_flag_set(get_local_flag(snapname))
def get_installed_snaps():
"""Return a list of snaps which are installed by this layer."""
flag_prefix = "snap.installed."
return [flag[len(flag_prefix) :] for flag in reactive.get_flags() if flag.startswith(flag_prefix)]
def refresh(snapname, **kw):
"""Update a snap.
Snap will be pulled from the coresponding resource if available
and reinstalled if it has changed. Otherwise a 'snap refresh' is
run updating the snap from the Snap Store, potentially switching
channel and changing confinement options.
"""
# Note that once you upload a resource, you can't remove it.
# This means we don't need to cope with an operator switching
# from a resource provided to a store provided snap, because there
# is no way for them to do that. Well, actually the operator could
# upload a zero byte resource, but then we would need to uninstall
# the snap before reinstalling from the store and that has the
# potential for data loss.
local_flag = get_local_flag(snapname)
if hookenv.has_juju_version("2.0"):
res_path = _resource_get(snapname)
if res_path is False:
_refresh_store(snapname, **kw)
reactive.clear_flag(local_flag)
else:
_install_local(res_path, **kw)
reactive.set_flag(local_flag)
else:
_refresh_store(snapname, **kw)
reactive.clear_flag(local_flag)
def remove(snapname):
hookenv.log("Removing snap {}".format(snapname))
subprocess.check_call(["snap", "remove", snapname])
reactive.clear_flag(get_installed_flag(snapname))
def connect(plug, slot):
"""Connect or reconnect a snap plug with a slot.
Each argument must be a two element tuple, corresponding to
the two arguments to the 'snap connect' command.
"""
hookenv.log("Connecting {} to {}".format(plug, slot), hookenv.DEBUG)
subprocess.check_call(["snap", "connect", plug, slot])
def connect_all():
"""Connect or reconnect all interface connections defined in layer.yaml.
This method will fail if called before all referenced snaps have been
installed.
"""
opts = layer.options("snap")
for snapname, snap_opts in opts.items():
for plug, slot in snap_opts.get("connect", []):
connect(plug, slot)
def disable(snapname):
"""Disables a snap in the system
Sets the snap.disabled.{snapname} flag
This method doesn't affect any snap flag if requested snap does not
exist
"""
hookenv.log("Disabling {} snap".format(snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot disable {} snap because it is not installed".format(snapname),
hookenv.WARNING,
)
return
subprocess.check_call(["snap", "disable", snapname])
reactive.set_flag(get_disabled_flag(snapname))
def enable(snapname):
"""Enables a snap in the system
Clears the snap.disabled.{snapname} flag
This method doesn't affect any snap flag if requeted snap does not
exist
"""
hookenv.log("Enabling {} snap".format(snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot enable {} snap because it is not installed".format(snapname),
hookenv.WARNING,
)
return
subprocess.check_call(["snap", "enable", snapname])
reactive.clear_flag(get_disabled_flag(snapname))
def restart(snapname):
"""Restarts a snap in the system
This method doesn't affect any snap flag if requested snap does not
exist
"""
hookenv.log("Restarting {} snap".format(snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot restart {} snap because it is not installed".format(snapname),
hookenv.WARNING,
)
return
subprocess.check_call(["snap", "restart", snapname])
def set(snapname, key, value):
"""Changes configuration options in a snap
This method will fail if snapname is not an installed snap
"""
hookenv.log("Set config {}={} for snap {}".format(key, value, snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot set {} snap config because it is not installed".format(snapname),
hookenv.WARNING,
)
return
subprocess.check_call(["snap", "set", snapname, "{}={}".format(key, value)])
def set_refresh_timer(timer=""):
"""Set the system refresh.timer option (snapd 2.31+)
This method sets how often snapd will refresh installed snaps. Call with
an empty timer string to use the system default (currently 4x per day).
Use 'max' to schedule refreshes as far into the future as possible
(currently 1 month). Also accepts custom timer strings as defined in the
refresh.timer section here:
https://forum.snapcraft.io/t/system-options/87
This method does not validate custom strings and will lead to a
CalledProcessError if an invalid string is given.
:param: timer: empty string (default), 'max', or custom string
"""
if timer == "max":
# A month from yesterday is the farthest we should delay to safely stay
# under the 1 month max. Translate that to a valid refresh.timer value.
# Examples:
# - Today is Friday the 13th, set the refresh timer to
# 'thu2' (Thursday the 12th is the 2nd thursday of the month).
# - Today is Tuesday the 1st, set the refresh timer to
# 'mon5' (Monday the [28..31] is the 5th monday of the month).
yesterday = datetime.now() - timedelta(1)
dow = yesterday.strftime("%a").lower()
# increment after int division because we want occurrence 1-5, not 0-4.
occurrence = yesterday.day // 7 + 1
timer = "{}{}".format(dow, occurrence)
# NB: 'system' became synonymous with 'core' in 2.32.5, but we use 'core'
# here to ensure max compatibility.
set(snapname="core", key="refresh.timer", value=timer)
subprocess.check_call(["systemctl", "restart", "snapd.service"])
def get(snapname, key):
"""Gets configuration options for a snap
This method returns the stripped output from the snap get command.
This method will fail if snapname is not an installed snap.
"""
hookenv.log("Get config {} for snap {}".format(key, snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot get {} snap config because it is not installed".format(snapname),
hookenv.WARNING,
)
return
return subprocess.check_output(["snap", "get", snapname, key]).strip()
def _snap_list():
"""Constructs a dict with all installed snaps.
Queries all the snaps installed and returns a dict containing their
versions and tracking channels, indexed by the snap name.
"""
cmd = ["snap", "list"]
out = subprocess.check_output(cmd).decode("utf-8", errors="replace").split()
snaps = {}
for i in range(6, len(out) - 5, 6): # Skip first six, which are the titles
# Snap list has 6 columns:
# name, version, revision, tracking channel, publisher and notes
# We only care about name (0), version (1) and tracking channel (3)
snaps[out[i]] = {
'version': out[i + 1],
'channel': out[i + 3],
}
return snaps
def get_installed_version(snapname):
"""Gets the installed version of a snapname.
This function will return nothing if snapname is not an installed snap.
"""
hookenv.log("Get installed key for snap {}".format(snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot get {} snap installed version because it is not installed".format(snapname),
hookenv.WARNING,
)
return
try:
return _snap_list()[snapname]['version']
except Exception as e:
# If it fails to get the version information(ex. installed via resource), return nothing.
hookenv.log(
"Cannot get snap version: {}".format(e),
hookenv.WARNING,
)
return
def get_installed_channel(snapname):
"""Gets the tracking (channel) of a snapname.
This function will return nothing if snapname is not an installed snap.
"""
hookenv.log("Get channel for snap {}".format(snapname))
if not reactive.is_flag_set(get_installed_flag(snapname)):
hookenv.log(
"Cannot get snap tracking (channel) because it is not installed",
hookenv.WARNING,
)
return
try:
return _snap_list()[snapname]['channel']
except Exception as e:
# If it fails to get the channel information(ex. installed via resource), return nothing.
hookenv.log(
"Cannot get snap tracking (channel): {}".format(e),
hookenv.WARNING,
)
return
def _snap_args(
channel="stable",
devmode=False,
jailmode=False,
dangerous=False,
force_dangerous=False,
connect=None,
classic=False,
revision=None,
):
yield "--channel={}".format(channel)
if devmode is True:
yield "--devmode"
if jailmode is True:
yield "--jailmode"
if force_dangerous is True or dangerous is True:
yield "--dangerous"
if classic is True:
yield "--classic"
if revision is not None:
yield "--revision={}".format(revision)
def _install_local(path, **kw):
key = "snap.local.{}".format(path)
if data_changed(key, kw) or any_file_changed([path]):
cmd = ["snap", "install"]
cmd.extend(_snap_args(**kw))
cmd.append("--dangerous")
cmd.append(path)
hookenv.log("Installing {} from local resource".format(path))
subprocess.check_call(cmd)
def _install_store(snapname, **kw):
"""Install snap from store
:param snapname: Name of snap to install
:type snapname: str
:param kw: Keyword arguments to pass on to ``snap install``
:type kw: Dict[str, str]
:raises: subprocess.CalledProcessError
"""
cmd = ["snap", "install"]
cmd.extend(_snap_args(**kw))
cmd.append(snapname)
hookenv.log("Installing {} from store".format(snapname))
# Use tenacity decorator for Trusty support (See LP Bug #1934163)
@tenacity.retry(
wait=tenacity.wait_fixed(10), # seconds
stop=tenacity.stop_after_attempt(3),
reraise=True,
)
def _run_install():
try:
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
hookenv.log(
'Installation successful cmd="{}" output="{}"'.format(cmd, out),
level=hookenv.DEBUG,
)
reactive.clear_flag(get_local_flag(snapname))
except subprocess.CalledProcessError as cp:
hookenv.log(
'Installation failed cmd="{}" returncode={} output="{}"'.format(cmd, cp.returncode, cp.output),
level=hookenv.ERROR,
)
raise
_run_install()
def _refresh_store(snapname, **kw):
if not data_changed("snap.opts.{}".format(snapname), kw):
return
# --amend allows us to refresh from a local resource
cmd = ["snap", "refresh", "--amend"]
cmd.extend(_snap_args(**kw))
cmd.append(snapname)
hookenv.log("Refreshing {} from store".format(snapname))
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
print(out)
def _resource_get(snapname):
"""Used to fetch the resource path of the given name.
This wrapper obtains a resource path and adds an additional
check to return False if the resource is zero length.
"""
res_path = hookenv.resource_get(snapname)
if res_path and os.stat(res_path).st_size != 0:
return res_path
return False
def get_available_refreshes():
"""Return a list of snaps which have refreshes available."""
try:
out = subprocess.check_output(["snap", "refresh", "--list"]).decode("utf8")
except subprocess.CalledProcessError:
# If snap refresh fails for whatever reason, we should just return no
# refreshes available - LP:1869630.
return []
if out == "All snaps up to date.":
return []
else:
return [line.split()[0] for line in out.splitlines()[1:]]
def is_refresh_available(snapname):
"""Check whether a new revision is available for the given snap."""
return reactive.is_flag_set(get_refresh_available_flag(snapname))
def _check_refresh_available(snapname):
return snapname in get_available_refreshes()
def create_cohort_snapshot(snapname):
"""Create a new cohort key for the given snap.
Cohort keys represent a snapshot of the revision of a snap at the time
the key was created. These keys can then be used on any machine to lock
the revision of the snap until a new cohort is joined (or the key expires,
after 90 days). This is used to maintain consistency of the revision of
the snap across units or applications, and to manage the refresh of the
snap in a controlled manner.
Returns a cohort key.
"""
out = subprocess.check_output(["snap", "create-cohort", snapname])
data = yaml.safe_load(out.decode("utf8"))
return data["cohorts"][snapname]["cohort-key"]
def join_cohort_snapshot(snapname, cohort_key):
"""Refresh the snap into the given cohort.
If the snap was previously in a cohort, this will update the revision
to that of the new cohort snapshot. Note that this does not change the
channel that the snap is in, only the revision within that channel.
"""
if is_local(snapname):
# joining a cohort can override a locally installed snap
hookenv.log("Skipping joining cohort for local snap: " "{}".format(snapname))
return
subprocess.check_output(["snap", "refresh", snapname, "--cohort", cohort_key])
# even though we just refreshed to the latest in the cohort, it's
# slightly possible that there's a newer rev available beyond the cohort
reactive.toggle_flag(get_refresh_available_flag(snapname), _check_refresh_available(snapname))