# Copyright (C) 2018 Canonical Ltd. # # This file is part of cloud-init. See LICENSE file for license information. """Snap: Install, configure and manage snapd and snap packages.""" import sys from textwrap import dedent from cloudinit import log as logging from cloudinit import subp, util from cloudinit.config.schema import ( MetaSchema, get_meta_doc, validate_cloudconfig_schema, ) from cloudinit.settings import PER_INSTANCE from cloudinit.subp import prepend_base_command distros = ["ubuntu"] frequency = PER_INSTANCE LOG = logging.getLogger(__name__) meta: MetaSchema = { "id": "cc_snap", "name": "Snap", "title": "Install, configure and manage snapd and snap packages", "description": dedent( """\ This module provides a simple configuration namespace in cloud-init to both setup snapd and install snaps. .. note:: Both ``assertions`` and ``commands`` values can be either a dictionary or a list. If these configs are provided as a dictionary, the keys are only used to order the execution of the assertions or commands and the dictionary is merged with any vendor-data snap configuration provided. If a list is provided by the user instead of a dict, any vendor-data snap configuration is ignored. The ``assertions`` configuration option is a dictionary or list of properly-signed snap assertions which will run before any snap ``commands``. They will be added to snapd's assertion database by invoking ``snap ack ``. Snap ``commands`` is a dictionary or list of individual snap commands to run on the target system. These commands can be used to create snap users, install snaps and provide snap configuration. .. note:: If 'side-loading' private/unpublished snaps on an instance, it is best to create a snap seed directory and seed.yaml manifest in **/var/lib/snapd/seed/** which snapd automatically installs on startup. **Development only**: The ``squashfuse_in_container`` boolean can be set true to install squashfuse package when in a container to enable snap installs. Default is false. """ ), "distros": distros, "examples": [ dedent( """\ snap: assertions: 00: | signed_assertion_blob_here 02: | signed_assertion_blob_here commands: 00: snap create-user --sudoer --known @mydomain.com 01: snap install canonical-livepatch 02: canonical-livepatch enable """ ), dedent( """\ # LXC-based containers require squashfuse before snaps can be installed snap: commands: 00: apt-get install squashfuse -y 11: snap install emoj """ ), dedent( """\ # Convenience: the snap command can be omitted when specifying commands # as a list and 'snap' will automatically be prepended. # The following commands are equivalent: snap: commands: 00: ['install', 'vlc'] 01: ['snap', 'install', 'vlc'] 02: snap install vlc 03: 'snap install vlc' """ ), dedent( """\ # You can use a list of commands snap: commands: - ['install', 'vlc'] - ['snap', 'install', 'vlc'] - snap install vlc - 'snap install vlc' """ ), dedent( """\ # You can use a list of assertions snap: assertions: - signed_assertion_blob_here - | signed_assertion_blob_here """ ), ], "frequency": PER_INSTANCE, } schema = { "type": "object", "properties": { "snap": { "type": "object", "properties": { "assertions": { "type": ["object", "array"], # Array of strings or dict "items": {"type": "string"}, "additionalItems": False, # Reject items non-string "minItems": 1, "minProperties": 1, "uniqueItems": True, "additionalProperties": {"type": "string"}, }, "commands": { "type": ["object", "array"], # Array of strings or dict "items": { "oneOf": [ {"type": "array", "items": {"type": "string"}}, {"type": "string"}, ] }, "additionalItems": False, # Reject non-string & non-list "minItems": 1, "minProperties": 1, "additionalProperties": { "oneOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}}, ], }, }, "squashfuse_in_container": {"type": "boolean"}, }, "additionalProperties": False, # Reject keys not in schema "minProperties": 1, } }, } __doc__ = get_meta_doc(meta, schema) # Supplement python help() SNAP_CMD = "snap" ASSERTIONS_FILE = "/var/lib/cloud/instance/snapd.assertions" def add_assertions(assertions): """Import list of assertions. Import assertions by concatenating each assertion into a string separated by a '\n'. Write this string to a instance file and then invoke `snap ack /path/to/file` and check for errors. If snap exits 0, then all assertions are imported. """ if not assertions: return LOG.debug("Importing user-provided snap assertions") if isinstance(assertions, dict): assertions = assertions.values() elif not isinstance(assertions, list): raise TypeError( "assertion parameter was not a list or dict: {assertions}".format( assertions=assertions ) ) snap_cmd = [SNAP_CMD, "ack"] combined = "\n".join(assertions) for asrt in assertions: LOG.debug("Snap acking: %s", asrt.split("\n")[0:2]) util.write_file(ASSERTIONS_FILE, combined.encode("utf-8")) subp.subp(snap_cmd + [ASSERTIONS_FILE], capture=True) def run_commands(commands): """Run the provided commands provided in snap:commands configuration. Commands are run individually. Any errors are collected and reported after attempting all commands. @param commands: A list or dict containing commands to run. Keys of a dict will be used to order the commands provided as dict values. """ if not commands: return LOG.debug("Running user-provided snap commands") if isinstance(commands, dict): # Sort commands based on dictionary key commands = [v for _, v in sorted(commands.items())] elif not isinstance(commands, list): raise TypeError( "commands parameter was not a list or dict: {commands}".format( commands=commands ) ) fixed_snap_commands = prepend_base_command("snap", commands) cmd_failures = [] for command in fixed_snap_commands: shell = isinstance(command, str) try: subp.subp(command, shell=shell, status_cb=sys.stderr.write) except subp.ProcessExecutionError as e: cmd_failures.append(str(e)) if cmd_failures: msg = "Failures running snap commands:\n{cmd_failures}".format( cmd_failures=cmd_failures ) util.logexc(LOG, msg) raise RuntimeError(msg) # RELEASE_BLOCKER: Once LP: #1628289 is released on xenial, drop this function. def maybe_install_squashfuse(cloud): """Install squashfuse if we are in a container.""" if not util.is_container(): return try: cloud.distro.update_package_sources() except Exception: util.logexc(LOG, "Package update failed") raise try: cloud.distro.install_packages(["squashfuse"]) except Exception: util.logexc(LOG, "Failed to install squashfuse") raise def handle(name, cfg, cloud, log, args): cfgin = cfg.get("snap", {}) if not cfgin: LOG.debug( "Skipping module named %s, no 'snap' key in configuration", name ) return validate_cloudconfig_schema(cfg, schema) if util.is_true(cfgin.get("squashfuse_in_container", False)): maybe_install_squashfuse(cloud) add_assertions(cfgin.get("assertions", [])) run_commands(cfgin.get("commands", [])) # vi: ts=4 expandtab