summaryrefslogtreecommitdiff
path: root/cloudinit/config/cc_snap.py
blob: db96529168097088309a6a80a6ec9d453b10819e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# 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.config.schema import (
    get_schema_doc, validate_cloudconfig_schema)
from cloudinit.settings import PER_INSTANCE
from cloudinit import util


distros = ['ubuntu']
frequency = PER_INSTANCE

LOG = logging.getLogger(__name__)

schema = {
    '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 <aggregate_assertion_file>``.

        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 <snap-user>@mydomain.com
              01: snap install canonical-livepatch
              02: canonical-livepatch enable <AUTH_TOKEN>
    """), 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'
    """)],
    'frequency': PER_INSTANCE,
    '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
                },
                '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,
                    'uniqueItems': True
                },
                'squashfuse_in_container': {
                    'type': 'boolean'
                }
            },
            'additionalProperties': False,  # Reject keys not in schema
            'required': [],
            'minProperties': 1
        }
    }
}

# TODO schema for 'assertions' and 'commands' are too permissive at the moment.
# Once python-jsonschema supports schema draft 6 add support for arbitrary
# object keys with 'patternProperties' constraint to validate string values.

__doc__ = get_schema_doc(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'))
    util.subp(snap_cmd + [ASSERTIONS_FILE], capture=True)


def prepend_snap_commands(commands):
    """Ensure user-provided commands start with SNAP_CMD, warn otherwise.

    Each command is either a list or string. Perform the following:
       - When the command is a list, pop the first element if it is None
       - When the command is a list, insert SNAP_CMD as the first element if
         not present.
       - When the command is a string containing a non-snap command, warn.

    Support cut-n-paste snap command sets from public snappy documentation.
    Allow flexibility to provide non-snap environment/config setup if needed.

    @commands: List of commands. Each command element is a list or string.

    @return: List of 'fixed up' snap commands.
    @raise: TypeError on invalid config item type.
    """
    warnings = []
    errors = []
    fixed_commands = []
    for command in commands:
        if isinstance(command, list):
            if command[0] is None:  # Avoid warnings by specifying None
                command = command[1:]
            elif command[0] != SNAP_CMD:  # Automatically prepend SNAP_CMD
                command.insert(0, SNAP_CMD)
        elif isinstance(command, str):
            if not command.startswith('%s ' % SNAP_CMD):
                warnings.append(command)
        else:
            errors.append(str(command))
            continue
        fixed_commands.append(command)

    if warnings:
        LOG.warning(
            'Non-snap commands in snap config:\n%s', '\n'.join(warnings))
    if errors:
        raise TypeError(
            'Invalid snap config.'
            ' These commands are not a string or list:\n' + '\n'.join(errors))
    return fixed_commands


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_snap_commands(commands)

    cmd_failures = []
    for command in fixed_snap_commands:
        shell = isinstance(command, str)
        try:
            util.subp(command, shell=shell, status_cb=sys.stderr.write)
        except util.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 as e:
        util.logexc(LOG, "Package update failed")
        raise
    try:
        cloud.distro.install_packages(['squashfuse'])
    except Exception as e:
        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