← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~chad.smith/cloud-init:feature/snap-module into cloud-init:master

 

Chad Smith has proposed merging ~chad.smith/cloud-init:feature/snap-module into cloud-init:master.

Requested reviews:
  Server Team CI bot (server-team-bot): continuous-integration
  cloud-init commiters (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/338366

cc_snap: Add new module to install and configure snapd and snap packages.

Support installing and configuring snaps on ubuntu systems. Now,
cloud-config files can provide a list or dictionary of snap:assertions 
which will be allow configuration of snapd on a system via 'snap ack' 
calls. The snap:commands configuration option supports arbitrary system
commands intended to interact with snappy's cli. This allows users to run
arbitrary snappy commands to create users, download, install and
configure snap packages and snapd.

This branch also deprecates old snappy and snap_config modules leaving
warnings in documentation and runtime for consumers of these modules.
Deprecated snap* modules will be dropped in cloud-init v.18.2 release.

-- 
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:feature/snap-module into cloud-init:master.
diff --git a/cloudinit/config/cc_snap.py b/cloudinit/config/cc_snap.py
new file mode 100644
index 0000000..0a27de8
--- /dev/null
+++ b/cloudinit/config/cc_snap.py
@@ -0,0 +1,216 @@
+# 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."""
+
+from textwrap import dedent
+
+from cloudinit.config.schema import (
+    get_schema_doc, validate_cloudconfig_schema)
+from cloudinit.settings import PER_INSTANCE
+from cloudinit import temp_utils
+from cloudinit import util
+
+distros = ['ubuntu']
+frequency = PER_INSTANCE
+
+
+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 of that list 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, 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
+
+    """)],
+    '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, log):
+    """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 ValueError(
+            '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 run_commands(commands, log):
+    """Run the provided commands provided in snap:commands configuration.
+
+     Commands are aggregated,shellified and written to a temporary script file
+     which is then executed.
+
+     @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 arbitrary dictionary key
+        commands = [v for _, v in sorted(commands.items())]
+    elif not isinstance(commands, list):
+        raise ValueError(
+            'commands parameter was not a list or dict: {commands}'.format(
+                commands=commands))
+    with temp_utils.ExtendedTemporaryFile(suffix=".sh") as tmpf:
+        try:
+            content = util.shellify(commands)
+            tmpf.write(util.encode_text(content))
+            tmpf.flush()
+        except Exception as e:
+            util.logexc(log, "Failed to shellify snap:commands: %s", str(e))
+            raise
+
+        try:
+            cmd = ['/bin/sh', tmpf.name]
+            util.subp(cmd, capture=False)
+        except Exception:
+            util.logexc(log, 'Failed to run snap commands')
+            raise
+
+
+def maybe_install_squashfuse(cloud, log):
+    """Install squashfuse if we are in a container."""
+    if not util.is_container():
+        return
+    log.warning('Snaps in containers require squashfuse per lp:1628289')
+    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, log)
+    add_assertions(cfgin.get('assertions', []), log=log)
+    run_commands(cfgin.get('commands', []), log=log)
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/config/cc_snap_config.py b/cloudinit/config/cc_snap_config.py
index e82c081..955b631 100644
--- a/cloudinit/config/cc_snap_config.py
+++ b/cloudinit/config/cc_snap_config.py
@@ -9,6 +9,9 @@ Snap Config
 -----------
 **Summary:** snap_config modules allows configuration of snapd.
 
+**Deprecated**: Use :ref:`snap` module instead. This module will not exist
+in cloud-init 18.2.
+
 This module uses the same ``snappy`` namespace for configuration but
 acts only only a subset of the configuration.
 
@@ -154,6 +157,9 @@ def handle(name, cfg, cloud, log, args):
         LOG.debug('No snappy config provided, skipping')
         return
 
+    log.warning(
+        'DEPRECATION: snap_config module will be dropped in 18.2 release.'
+        ' Use snap module instead')
     if not(util.system_is_snappy()):
         LOG.debug("%s: system not snappy", name)
         return
diff --git a/cloudinit/config/cc_snappy.py b/cloudinit/config/cc_snappy.py
index eecb817..bb0fcbc 100644
--- a/cloudinit/config/cc_snappy.py
+++ b/cloudinit/config/cc_snappy.py
@@ -5,6 +5,9 @@ Snappy
 ------
 **Summary:** snappy modules allows configuration of snappy.
 
+**Deprecated**: Use :ref:`snap` module instead. This module will not exist
+in cloud-init 18.2.
+
 The below example config config would install ``etcd``, and then install
 ``pkg2.smoser`` with a ``<config-file>`` argument where ``config-file`` has
 ``config-blob`` inside it. If ``pkgname`` is installed already, then
@@ -271,6 +274,10 @@ def handle(name, cfg, cloud, log, args):
         LOG.debug("%s: 'auto' mode, and system not snappy", name)
         return
 
+    log.warning(
+        'DEPRECATION: snappy module will be dropped in 18.2 release.'
+        ' Use snap module instead')
+
     set_snappy_command()
 
     pkg_ops = get_package_ops(packages=mycfg['packages'],
diff --git a/cloudinit/config/tests/test_snap.py b/cloudinit/config/tests/test_snap.py
new file mode 100644
index 0000000..ce45272
--- /dev/null
+++ b/cloudinit/config/tests/test_snap.py
@@ -0,0 +1,504 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit.config.cc_snap import (
+    ASSERTIONS_FILE, add_assertions, handle, maybe_install_squashfuse,
+    run_commands, schema)
+from cloudinit.config.schema import validate_cloudconfig_schema
+from cloudinit import util
+from cloudinit.tests.helpers import CiTestCase, mock, wrap_and_call
+
+import logging
+
+LOG = logging.getLogger(__name__)
+
+
+SYSTEM_USER_ASSERTION = """\
+type: system-user
+authority-id: LqvZQdfyfGlYvtep4W6Oj6pFXP9t1Ksp
+brand-id: LqvZQdfyfGlYvtep4W6Oj6pFXP9t1Ksp
+email: foo@xxxxxxx
+password: $6$E5YiAuMIPAwX58jG$miomhVNui/vf7f/3ctB/f0RWSKFxG0YXzrJ9rtJ1ikvzt
+series:
+- 16
+since: 2016-09-10T16:34:00+03:00
+until: 2017-11-10T16:34:00+03:00
+username: baz
+sign-key-sha3-384: RuVvnp4n52GilycjfbbTCI3_L8Y6QlIE75wxMc0KzGV3AUQqVd9GuXoj
+
+AcLBXAQAAQoABgUCV/UU1wAKCRBKnlMoJQLkZVeLD/9/+hIeVywtzsDA3oxl+P+u9D13y9s6svP
+Jd6Wnf4FTw6sq1GjBE4ZA7lrwSaRCUJ9Vcsvf2q9OGPY7mOb2TBxaDe0PbUMjrSrqllSSQwhpNI
+zG+NxkkKuxsUmLzFa+k9m6cyojNbw5LFhQZBQCGlr3JYqC0tIREq/UsZxj+90TUC87lDJwkU8GF
+s4CR+rejZj4itIcDcVxCSnJH6hv6j2JrJskJmvObqTnoOlcab+JXdamXqbldSP3UIhWoyVjqzkj
++to7mXgx+cCUA9+ngNCcfUG+1huGGTWXPCYkZ78HvErcRlIdeo4d3xwtz1cl/w3vYnq9og1XwsP
+Yfetr3boig2qs1Y+j/LpsfYBYncgWjeDfAB9ZZaqQz/oc8n87tIPZDJHrusTlBfop8CqcM4xsKS
+d+wnEY8e/F24mdSOYmS1vQCIDiRU3MKb6x138Ud6oHXFlRBbBJqMMctPqWDunWzb5QJ7YR0I39q
+BrnEqv5NE0G7w6HOJ1LSPG5Hae3P4T2ea+ATgkb03RPr3KnXnzXg4TtBbW1nytdlgoNc/BafE1H
+f3NThcq9gwX4xWZ2PAWnqVPYdDMyCtzW3Ck+o6sIzx+dh4gDLPHIi/6TPe/pUuMop9CBpWwez7V
+v1z+1+URx6Xlq3Jq18y5pZ6fY3IDJ6km2nQPMzcm4Q=="""
+
+ACCOUNT_ASSERTION = """\
+type: account-key
+authority-id: canonical
+revision: 2
+public-key-sha3-384: BWDEoaqyr25nF5SNCvEv2v7QnM9QsfCc0PBMYD_i2NGSQ32EF2d4D0
+account-id: canonical
+name: store
+since: 2016-04-01T00:00:00.0Z
+body-length: 717
+sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswH
+
+AcbBTQRWhcGAARAA0KKYYQWuHOrsFVi4p4l7ZzSvX7kLgJFFeFgOkzdWKBTHEnsMKjl5mefFe9j
+qe8NlmJdfY7BenP7XeBtwKp700H/t9lLrZbpTNAPHXYxEWFJp5bPqIcJYBZ+29oLVLN1Tc5X482
+vCiDqL8+pPYqBrK2fNlyPlNNSum9wI70rDDL4r6FVvr+osTnGejibdV8JphWX+lrSQDnRSdM8KJ
+UM43vTgLGTi9W54oRhsA2OFexRfRksTrnqGoonCjqX5wO3OFSaMDzMsO2MJ/hPfLgDqw53qjzuK
+Iec9OL3k5basvu2cj5u9tKwVFDsCKK2GbKUsWWpx2KTpOifmhmiAbzkTHbH9KaoMS7p0kJwhTQG
+o9aJ9VMTWHJc/NCBx7eu451u6d46sBPCXS/OMUh2766fQmoRtO1OwCTxsRKG2kkjbMn54UdFULl
+VfzvyghMNRKIezsEkmM8wueTqGUGZWa6CEZqZKwhe/PROxOPYzqtDH18XZknbU1n5lNb7vNfem9
+2ai+3+JyFnW9UhfvpVF7gzAgdyCqNli4C6BIN43uwoS8HkykocZS/+Gv52aUQ/NZ8BKOHLw+7an
+Q0o8W9ltSLZbEMxFIPSN0stiZlkXAp6DLyvh1Y4wXSynDjUondTpej2fSvSlCz/W5v5V7qA4nIc
+vUvV7RjVzv17ut0AEQEAAQ==
+
+AcLDXAQAAQoABgUCV83k9QAKCRDUpVvql9g3IBT8IACKZ7XpiBZ3W4lqbPssY6On81WmxQLtvsM
+WTp6zZpl/wWOSt2vMNUk9pvcmrNq1jG9CuhDfWFLGXEjcrrmVkN3YuCOajMSPFCGrxsIBLSRt/b
+nrKykdLAAzMfG8rP1d82bjFFiIieE+urQ0Kcv09Jtdvavq3JT1Tek5mFyyfhHNlQEKOzWqmRWiL
+3c3VOZUs1ZD8TSlnuq/x+5T0X0YtOyGjSlVxk7UybbyMNd6MZfNaMpIG4x+mxD3KHFtBAC7O6kL
+eX3i6j5nCY5UABfA3DZEAkWP4zlmdBEOvZ9t293NaDdOpzsUHRkoi0Zez/9BHQ/kwx/uNc2WqrY
+inCmu16JGNeXqsyinnLl7Ghn2RwhvDMlLxF6RTx8xdx1yk6p3PBTwhZMUvuZGjUtN/AG8BmVJQ1
+rsGSRkkSywvnhVJRB2sudnrMBmNS2goJbzSbmJnOlBrd2WsV0T9SgNMWZBiov3LvU4o2SmAb6b+
+rYwh8H5QHcuuYJuxDjFhPswIp6Wes5T6hUicf3SWtObcDS4HSkVS4ImBjjX9YgCuFy7QdnooOWE
+aPvkRw3XCVeYq0K6w9GRsk1YFErD4XmXXZjDYY650MX9v42Sz5MmphHV8jdIY5ssbadwFSe2rCQ
+6UX08zy7RsIb19hTndE6ncvSNDChUR9eEnCm73eYaWTWTnq1cxdVP/s52r8uss++OYOkPWqh5nO
+haRn7INjH/yZX4qXjNXlTjo0PnHH0q08vNKDwLhxS+D9du+70FeacXFyLIbcWllSbJ7DmbumGpF
+yYbtj3FDDPzachFQdIG3lSt+cSUGeyfSs6wVtc3cIPka/2Urx7RprfmoWSI6+a5NcLdj0u2z8O9
+HxeIgxDpg/3gT8ZIuFKePMcLDM19Fh/p0ysCsX+84B9chNWtsMSmIaE57V+959MVtsLu7SLb9gi
+skrju0pQCwsu2wHMLTNd1f3PTHmrr49hxetTus07HSQUApMtAGKzQilF5zqFjbyaTd4xgQbd+PK
+CjFyzQTDOcUhXpuUGt/IzlqiFfsCsmbj2K4KdSNYMlqIgZ3Azu8KvZLIhsyN7v5vNIZSPfEbjde
+ClU9r0VRiJmtYBUjcSghD9LWn+yRLwOxhfQVjm0cBwIt5R/yPF/qC76yIVuWUtM5Y2/zJR1J8OF
+qWchvlImHtvDzS9FQeLyzJAOjvZ2CnWp2gILgUz0WQdOk1Dq8ax7KS9BQ42zxw9EZAEPw3PEFqR
+IQsRTONp+iVS8YxSmoYZjDlCgRMWUmawez/Fv5b9Fb/XkO5Eq4e+KfrpUujXItaipb+tV8h5v3t
+oG3Ie3WOHrVjCLXIdYslpL1O4nadqR6Xv58pHj6k"""
+
+
+class FakeCloud(object):
+    def __init__(self, distro):
+        self.distro = distro
+
+
+class TestAddAssertions(CiTestCase):
+
+    with_logs = True
+
+    def setUp(self):
+        super(TestAddAssertions, self).setUp()
+        self.tmp = self.tmp_dir()
+
+    @mock.patch('cloudinit.config.cc_snap.util.subp')
+    def test_add_assertions_on_empty_list(self, m_subp):
+        """When provided with an empty list, add_assertions does nothing."""
+        add_assertions([], log=self.logger)
+        self.assertEqual('', self.logs.getvalue())
+        m_subp.assert_not_called()
+
+    def test_add_assertions_on_non_list_or_dict(self):
+        """When provided an invalid type, add_assertions raises an error."""
+        with self.assertRaises(ValueError) as context_manager:
+            add_assertions(assertions="I'm Not Valid", log=self.logger)
+        self.assertEqual(
+            "assertion parameter was not a list or dict: I'm Not Valid",
+            str(context_manager.exception))
+
+    @mock.patch('cloudinit.config.cc_snap.util.subp')
+    def test_add_assertions_adds_assertions_as_list(self, m_subp):
+        """When provided with a list, add_assertions adds all assertions."""
+        self.assertEqual(
+            ASSERTIONS_FILE, '/var/lib/cloud/instance/snapd.assertions')
+        assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+        assertions = [SYSTEM_USER_ASSERTION, ACCOUNT_ASSERTION]
+        wrap_and_call(
+            'cloudinit.config.cc_snap',
+            {'ASSERTIONS_FILE': {'new': assert_file}},
+            add_assertions, assertions, log=self.logger)
+        self.assertIn(
+            'Importing user-provided snap assertions', self.logs.getvalue())
+        self.assertIn(
+            'sertions', self.logs.getvalue())
+        self.assertEqual(
+            [mock.call(['snap', 'ack', assert_file], capture=True)],
+            m_subp.call_args_list)
+        compare_file = self.tmp_path('comparison', dir=self.tmp)
+        util.write_file(compare_file, '\n'.join(assertions).encode('utf-8'))
+        self.assertEqual(
+            util.load_file(compare_file), util.load_file(assert_file))
+
+    @mock.patch('cloudinit.config.cc_snap.util.subp')
+    def test_add_assertions_adds_assertions_as_dict(self, m_subp):
+        """When provided with a dict, add_assertions adds all assertions."""
+        self.assertEqual(
+            ASSERTIONS_FILE, '/var/lib/cloud/instance/snapd.assertions')
+        assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+        assertions = {'00': SYSTEM_USER_ASSERTION, '01': ACCOUNT_ASSERTION}
+        wrap_and_call(
+            'cloudinit.config.cc_snap',
+            {'ASSERTIONS_FILE': {'new': assert_file}},
+            add_assertions, assertions, log=self.logger)
+        self.assertIn(
+            'Importing user-provided snap assertions', self.logs.getvalue())
+        self.assertIn(
+            "DEBUG: Snap acking: ['type: system-user', 'authority-id: Lqv",
+            self.logs.getvalue())
+        self.assertIn(
+            "DEBUG: Snap acking: ['type: account-key', 'authority-id: canonic",
+            self.logs.getvalue())
+        self.assertEqual(
+            [mock.call(['snap', 'ack', assert_file], capture=True)],
+            m_subp.call_args_list)
+        compare_file = self.tmp_path('comparison', dir=self.tmp)
+        combined = '\n'.join(assertions.values())
+        util.write_file(compare_file, combined.encode('utf-8'))
+        self.assertEqual(
+            util.load_file(compare_file), util.load_file(assert_file))
+
+
+class TestRunCommands(CiTestCase):
+
+    with_logs = True
+
+    def setUp(self):
+        super(TestRunCommands, self).setUp()
+        self.tmp = self.tmp_dir()
+
+    @mock.patch('cloudinit.config.cc_snap.util.subp')
+    def test_run_commands_on_empty_list(self, m_subp):
+        """When provided with an empty list, run_commands does nothing."""
+        run_commands([], log=self.logger)
+        self.assertEqual('', self.logs.getvalue())
+        m_subp.assert_not_called()
+
+    def test_run_commands_on_non_list_or_dict(self):
+        """When provided an invalid type, run_commands raises an error."""
+        with self.assertRaises(ValueError) as context_manager:
+            run_commands(commands="I'm Not Valid", log=self.logger)
+        self.assertEqual(
+            "commands parameter was not a list or dict: I'm Not Valid",
+            str(context_manager.exception))
+
+    def test_run_command_list_as_a_temp_script(self):
+        """When commands are specified as a list, run them as a temp script."""
+        script = self.tmp_path('commands.sh', dir=self.tmp)
+        outfile = self.tmp_path('output.log', dir=self.tmp)
+
+        class FakeExtendedTempFile(object):
+
+            def __init__(self, suffix):
+                assert(suffix == '.sh')
+                self.handle = open(script, 'wb')
+
+            def __enter__(self):
+                return self.handle
+
+            def __exit__(self, exc_type, exc_value, traceback):
+                self.handle.close()
+                util.del_file(self.handle.name)
+
+        commands = ['echo "HI" >> %s' % outfile, 'echo "MOM" >> %s' % outfile]
+        mockpath = 'cloudinit.config.cc_snap.temp_utils.ExtendedTemporaryFile'
+        with mock.patch(mockpath, FakeExtendedTempFile):
+            run_commands(commands=commands, log=self.logger)
+        expected_messages = [
+            'DEBUG: Running user-provided snap commands',
+            'DEBUG: Shellified 2 commands.',
+            "DEBUG: Running command ['/bin/sh', '%s']" % script]
+        for message in expected_messages:
+            self.assertIn(message, self.logs.getvalue())
+        self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+    def test_run_command_dict_sorted_as_command_script(self):
+        """When commands are a dict, sort them and run as a temp script."""
+        script = self.tmp_path('commands.sh', dir=self.tmp)
+        outfile = self.tmp_path('output.log', dir=self.tmp)
+
+        class FakeExtendedTempFile(object):
+
+            def __init__(self, suffix):
+                assert(suffix == '.sh')
+                self.handle = open(script, 'wb')
+
+            def __enter__(self):
+                return self.handle
+
+            def __exit__(self, exc_type, exc_value, traceback):
+                self.handle.close()
+                util.del_file(self.handle.name)
+
+        commands = {'02': 'echo "HI" >> %s' % outfile,
+                    '01': 'echo "MOM" >> %s' % outfile}
+        mockpath = 'cloudinit.config.cc_snap.temp_utils.ExtendedTemporaryFile'
+        with mock.patch(mockpath, FakeExtendedTempFile):
+            run_commands(commands=commands, log=self.logger)
+        expected_messages = [
+            'DEBUG: Running user-provided snap commands',
+            'DEBUG: Shellified 2 commands.',
+            "DEBUG: Running command ['/bin/sh', '%s']" % script]
+        for message in expected_messages:
+            self.assertIn(message, self.logs.getvalue())
+        self.assertEqual('MOM\nHI\n', util.load_file(outfile))
+
+
+class TestSchema(CiTestCase):
+
+    with_logs = True
+
+    def test_schema_warns_on_snap_not_as_dict(self):
+        """If the snap configuration is not a dict, emit a warning."""
+        validate_cloudconfig_schema({'snap': 'wrong type'}, schema)
+        self.assertEqual(
+            "WARNING: Invalid config:\nsnap: 'wrong type' is not of type"
+            " 'object'\n",
+            self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.run_commands')
+    def test_schema_disallows_unknown_keys(self, _):
+        """Unknown keys in the snap configuration emit warnings."""
+        validate_cloudconfig_schema(
+            {'snap': {'commands': ['ls'], 'invalid-key': ''}}, schema)
+        self.assertIn(
+            'WARNING: Invalid config:\nsnap: Additional properties are not'
+            " allowed ('invalid-key' was unexpected)",
+            self.logs.getvalue())
+
+    def test_warn_schema_requires_either_commands_or_assertions(self):
+        """Warn when snap configuration lacks both commands and assertions."""
+        validate_cloudconfig_schema(
+            {'snap': {}}, schema)
+        self.assertIn(
+            'WARNING: Invalid config:\nsnap: {} does not have enough'
+            ' properties',
+            self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.run_commands')
+    def test_warn_schema_commands_is_not_list_or_dict(self, _):
+        """Warn when snap:commands config is not a list or dict."""
+        validate_cloudconfig_schema(
+            {'snap': {'commands': 'broken'}}, schema)
+        self.assertEqual(
+            "WARNING: Invalid config:\nsnap.commands: 'broken' is not of type"
+            " 'object', 'array'\n",
+            self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.run_commands')
+    def test_warn_schema_when_commands_is_empty(self, _):
+        """Emit warnings when snap:commands is an empty list or dict."""
+        validate_cloudconfig_schema(
+            {'snap': {'commands': []}}, schema)
+        validate_cloudconfig_schema(
+            {'snap': {'commands': {}}}, schema)
+        self.assertEqual(
+            "WARNING: Invalid config:\nsnap.commands: [] is too short\n"
+            "WARNING: Invalid config:\nsnap.commands: {} does not have enough"
+            " properties\n",
+            self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.run_commands')
+    def test_schema_when_commands_are_list_or_dict(self, _):
+        """No warnings when snap:commands are either a list or dict."""
+        validate_cloudconfig_schema(
+            {'snap': {'commands': ['valid']}}, schema)
+        validate_cloudconfig_schema(
+            {'snap': {'commands': {'01': 'also valid'}}}, schema)
+        self.assertEqual('', self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.add_assertions')
+    def test_warn_schema_assertions_is_not_list_or_dict(self, _):
+        """Warn when snap:assertions config is not a list or dict."""
+        validate_cloudconfig_schema(
+            {'snap': {'assertions': 'broken'}}, schema)
+        self.assertEqual(
+            "WARNING: Invalid config:\nsnap.assertions: 'broken' is not of"
+            " type 'object', 'array'\n",
+            self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.add_assertions')
+    def test_warn_schema_when_assertions_is_empty(self, _):
+        """Emit warnings when snap:assertions is an empty list or dict."""
+        validate_cloudconfig_schema(
+            {'snap': {'assertions': []}}, schema)
+        validate_cloudconfig_schema(
+            {'snap': {'assertions': {}}}, schema)
+        self.assertEqual(
+            "WARNING: Invalid config:\nsnap.assertions: [] is too short\n"
+            "WARNING: Invalid config:\nsnap.assertions: {} does not have"
+            " enough properties\n",
+            self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.add_assertions')
+    def test_schema_when_assertions_are_list_or_dict(self, _):
+        """No warnings when snap:assertions are a list or dict."""
+        validate_cloudconfig_schema(
+            {'snap': {'assertions': ['valid']}}, schema)
+        validate_cloudconfig_schema(
+            {'snap': {'assertions': {'01': 'also valid'}}}, schema)
+        self.assertEqual('', self.logs.getvalue())
+
+
+class TestHandle(CiTestCase):
+
+    with_logs = True
+
+    def setUp(self):
+        super(TestHandle, self).setUp()
+        self.tmp = self.tmp_dir()
+
+    @mock.patch('cloudinit.config.cc_snap.run_commands')
+    @mock.patch('cloudinit.config.cc_snap.add_assertions')
+    @mock.patch('cloudinit.config.cc_snap.validate_cloudconfig_schema')
+    def test_handle_no_config(self, m_schema, m_add, m_run):
+        """When no snap-related configuration is provided, nothing happens."""
+        cfg = {}
+        handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+        self.assertIn(
+            "DEBUG: Skipping module named snap, no 'snap' key in config",
+            self.logs.getvalue())
+        m_schema.assert_not_called()
+        m_add.assert_not_called()
+        m_run.assert_not_called()
+
+    @mock.patch('cloudinit.config.cc_snap.run_commands')
+    @mock.patch('cloudinit.config.cc_snap.add_assertions')
+    @mock.patch('cloudinit.config.cc_snap.maybe_install_squashfuse')
+    def test_handle_skips_squashfuse_when_unconfigured(self, m_squash, m_add,
+                                                       m_run):
+        """When squashfuse_in_container is unset, don't attempt to install."""
+        handle(
+            'snap', cfg={'snap': {}}, cloud=None, log=self.logger, args=None)
+        handle(
+            'snap', cfg={'snap': {'squashfuse_in_container': None}},
+            cloud=None, log=self.logger, args=None)
+        handle(
+            'snap', cfg={'snap': {'squashfuse_in_container': False}},
+            cloud=None, log=self.logger, args=None)
+        self.assertEqual([], m_squash.call_args_list)  # No calls
+        # snap configuration missing assertions and commands will default to []
+        self.assertIn(mock.call([], log=self.logger), m_add.call_args_list)
+        self.assertIn(mock.call([], log=self.logger), m_run.call_args_list)
+
+    @mock.patch('cloudinit.config.cc_snap.maybe_install_squashfuse')
+    def test_handle_tries_to_install_squashfuse(self, m_squash):
+        """If squashfuse_in_container is True, try installing squashfuse."""
+        cfg = {'snap': {'squashfuse_in_container': True}}
+        mycloud = FakeCloud(None)
+        handle('snap', cfg=cfg, cloud=mycloud, log=self.logger, args=None)
+        self.assertEqual(
+            [mock.call(mycloud, self.logger)], m_squash.call_args_list)
+
+    def test_handle_runs_commands_provided(self):
+        """If commands are specified as a list, run them."""
+        script = self.tmp_path('commands.sh', dir=self.tmp)
+        outfile = self.tmp_path('output.log', dir=self.tmp)
+
+        class FakeExtendedTempFile(object):
+            def __init__(self, suffix):
+                assert(suffix == '.sh')
+                self.handle = open(script, 'wb')
+
+            def __enter__(self):
+                return self.handle
+
+            def __exit__(self, exc_type, exc_value, traceback):
+                self.handle.close()
+                util.del_file(self.handle.name)
+
+        cfg = {
+            'snap': {'commands': ['echo "HI" >> %s' % outfile,
+                                  'echo "MOM" >> %s' % outfile]}}
+        mockpath = 'cloudinit.config.cc_snap.temp_utils.ExtendedTemporaryFile'
+        with mock.patch(mockpath, FakeExtendedTempFile):
+            handle('snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+        self.assertEqual('HI\nMOM\n', util.load_file(outfile))
+
+    @mock.patch('cloudinit.config.cc_snap.util.subp')
+    def test_handle_adds_assertions(self, m_subp):
+        """Any configured snap assertions are provided to add_assertions."""
+        assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+        compare_file = self.tmp_path('comparison', dir=self.tmp)
+        cfg = {
+            'snap': {'assertions': [SYSTEM_USER_ASSERTION, ACCOUNT_ASSERTION]}}
+        wrap_and_call(
+            'cloudinit.config.cc_snap',
+            {'ASSERTIONS_FILE': {'new': assert_file}},
+            handle, 'snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+        content = '\n'.join(cfg['snap']['assertions'])
+        util.write_file(compare_file, content.encode('utf-8'))
+        self.assertEqual(
+            util.load_file(compare_file), util.load_file(assert_file))
+
+    @mock.patch('cloudinit.config.cc_snap.util.subp')
+    def test_handle_validates_schema(self, m_subp):
+        """Any provided configuration is runs validate_cloudconfig_schema."""
+        assert_file = self.tmp_path('snapd.assertions', dir=self.tmp)
+        cfg = {'snap': {'invalid': ''}}  # Generates schema warning
+        wrap_and_call(
+            'cloudinit.config.cc_snap',
+            {'ASSERTIONS_FILE': {'new': assert_file}},
+            handle, 'snap', cfg=cfg, cloud=None, log=self.logger, args=None)
+        self.assertEqual(
+            "WARNING: Invalid config:\nsnap: Additional properties are not"
+            " allowed ('invalid' was unexpected)\n",
+            self.logs.getvalue())
+
+
+class TestMaybeInstallSquashFuse(CiTestCase):
+
+    with_logs = True
+
+    def setUp(self):
+        super(TestMaybeInstallSquashFuse, self).setUp()
+        self.tmp = self.tmp_dir()
+
+    @mock.patch('cloudinit.config.cc_snap.util.is_container')
+    def test_maybe_install_squashfuse_skips_non_containers(self, m_container):
+        """maybe_install_squashfuse does nothing when not on a container."""
+        m_container.return_value = False
+        maybe_install_squashfuse(cloud=FakeCloud(None), log=self.logger)
+        self.assertEqual([mock.call()], m_container.call_args_list)
+        self.assertEqual('', self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.util.is_container')
+    def test_maybe_install_squashfuse_raises_install_errors(self, m_container):
+        """maybe_install_squashfuse logs and raises package install errors."""
+        m_container.return_value = True
+        distro = mock.MagicMock()
+        distro.update_package_sources.side_effect = RuntimeError(
+            'Some apt error')
+        with self.assertRaises(RuntimeError) as context_manager:
+            maybe_install_squashfuse(cloud=FakeCloud(distro), log=self.logger)
+        self.assertEqual('Some apt error', str(context_manager.exception))
+        self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.util.is_container')
+    def test_maybe_install_squashfuse_raises_update_errors(self, m_container):
+        """maybe_install_squashfuse logs and raises package update errors."""
+        m_container.return_value = True
+        distro = mock.MagicMock()
+        distro.update_package_sources.side_effect = RuntimeError(
+            'Some apt error')
+        with self.assertRaises(RuntimeError) as context_manager:
+            maybe_install_squashfuse(cloud=FakeCloud(distro), log=self.logger)
+        self.assertEqual('Some apt error', str(context_manager.exception))
+        self.assertIn('Package update failed\nTraceback', self.logs.getvalue())
+
+    @mock.patch('cloudinit.config.cc_snap.util.is_container')
+    def test_maybe_install_squashfuse_happy_path(self, m_container):
+        """maybe_install_squashfuse logs and raises package install errors."""
+        m_container.return_value = True
+        distro = mock.MagicMock()  # No errors raised
+        maybe_install_squashfuse(cloud=FakeCloud(distro), log=self.logger)
+        self.assertIn(
+            'WARNING: Snaps in containers require squashfuse per lp:1628289',
+            self.logs.getvalue())
+        self.assertEqual(
+            [mock.call()], distro.update_package_sources.call_args_list)
+        self.assertEqual(
+            [mock.call(['squashfuse'])],
+            distro.install_packages.call_args_list)
+
+# vi: ts=4 expandtab
diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
index fad1184..42834a0 100644
--- a/config/cloud.cfg.tmpl
+++ b/config/cloud.cfg.tmpl
@@ -72,6 +72,7 @@ cloud_config_modules:
 # Emit the cloud config ready event
 # this can be used by upstart jobs for 'start on cloud-config'.
  - emit_upstart
+ - snap
  - snap_config
 {% endif %}
  - ssh-import-id
diff --git a/doc/rtd/conf.py b/doc/rtd/conf.py
index 0ea3b6b..50eb05c 100644
--- a/doc/rtd/conf.py
+++ b/doc/rtd/conf.py
@@ -29,6 +29,7 @@ project = 'Cloud-Init'
 extensions = [
     'sphinx.ext.intersphinx',
     'sphinx.ext.autodoc',
+    'sphinx.ext.autosectionlabel',
     'sphinx.ext.viewcode',
 ]
 
diff --git a/doc/rtd/topics/modules.rst b/doc/rtd/topics/modules.rst
index 7b14675..a0f6812 100644
--- a/doc/rtd/topics/modules.rst
+++ b/doc/rtd/topics/modules.rst
@@ -45,6 +45,7 @@ Modules
 .. automodule:: cloudinit.config.cc_seed_random
 .. automodule:: cloudinit.config.cc_set_hostname
 .. automodule:: cloudinit.config.cc_set_passwords
+.. automodule:: cloudinit.config.cc_snap
 .. automodule:: cloudinit.config.cc_snappy
 .. automodule:: cloudinit.config.cc_snap_config
 .. automodule:: cloudinit.config.cc_spacewalk
diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
index 20e9595..1e912c7 100644
--- a/tests/cloud_tests/testcases/base.py
+++ b/tests/cloud_tests/testcases/base.py
@@ -6,8 +6,11 @@ import crypt
 import json
 import unittest
 
+
 from cloudinit import util as c_util
 
+SkipTest = unittest.SkipTest
+
 
 class CloudTestCase(unittest.TestCase):
     """Base test class for verifiers."""
@@ -18,6 +21,14 @@ class CloudTestCase(unittest.TestCase):
     _cloud_config = None
 
     @property
+    def os_name(self):
+        return self.data.get('os_name', 'UNKNOWN')
+
+    @property
+    def platform(self):
+        return self.data.get('platform', 'UNKNOWN')
+
+    @property
     def cloud_config(self):
         """Get the cloud-config used by the test."""
         if not self._cloud_config:
diff --git a/tests/cloud_tests/testcases/modules/snap.py b/tests/cloud_tests/testcases/modules/snap.py
new file mode 100644
index 0000000..7120e3f
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/snap.py
@@ -0,0 +1,132 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""cloud-init Integration Test Verify Script"""
+import json
+import re
+
+from tests.cloud_tests.testcases import base
+
+
+class TestSnap(base.CloudTestCase):
+    """Test snap module"""
+
+    def test_snappy_version(self):
+        """Expect hello-world and core snaps are installed."""
+        out = self.get_data_file('snaplist')
+        self.assertIn('core', out)
+        self.assertIn('hello-world', out)
+
+    def test_instance_data_json_ec2(self):
+        """Validate instance-data.json content by ec2 platform.
+
+        This content is sourced by snapd when determining snapstore endpoints.
+        We validate expected values per cloud type to ensure we don't break
+        snapd.
+        """
+        if self.platform != 'ec2':
+            raise base.SkipTest(
+                'Skipping ec2 instance-data.json on %s' % self.platform)
+        out = self.get_data_file('instance-data.json')
+        if not out:
+            if self.os_name == 'bionic':
+                raise AssertionError('No instance-data.json found on bionic')
+            raise base.SkipTest(
+                'Skipping instance-data.json test. OS: %s not bionic' %
+                self.os_name)
+        instance_data = json.loads(out)
+        self.assertEqual(
+            ['ds/user-data'], instance_data['base64-encoded-keys'])
+        ds = instance_data.get('ds', {})
+        macs = ds.get('network', {}).get('interfaces', {}).get('macs', {})
+        if not macs:
+            raise AssertionError('No network data from EC2 meta-data')
+        # Check meta-data items we depend on
+        expected_net_keys = [
+            'public-ipv4s', 'ipv4-associations', 'local-hostname',
+            'public-hostname']
+        for mac, mac_data in macs.items():
+            for key in expected_net_keys:
+                self.assertIn(key, mac_data)
+        self.assertIsNotNone(
+            ds.get('placement', {}).get('availability-zone'),
+            'Could not determine EC2 Availability zone placement')
+        ds = instance_data.get('ds', {})
+        v1_data = instance_data.get('v1', {})
+        self.assertIsNotNone(
+            v1_data['availability-zone'], 'expected ec2 availability-zone')
+        self.assertEqual('aws', v1_data['cloud-name'])
+        self.assertIn('i-', v1_data['instance-id'])
+        self.assertIn('ip-', v1_data['local-hostname'])
+        self.assertIsNotNone(v1_data['region'], 'expected ec2 region')
+
+    def test_instance_data_json_lxd(self):
+        """Validate instance-data.json content by lxd platform.
+
+        This content is sourced by snapd when determining snapstore endpoints.
+        We validate expected values per cloud type to ensure we don't break
+        snapd.
+        """
+        if self.platform != 'lxd':
+            raise base.SkipTest(
+                'Skipping lxd instance-data.json on %s' % self.platform)
+        out = self.get_data_file('instance-data.json')
+        if not out:
+            if self.os_name == 'bionic':
+                raise AssertionError('No instance-data.json found on bionic')
+            raise base.SkipTest(
+                'Skipping instance-data.json test. OS: %s not bionic' %
+                self.os_name)
+        instance_data = json.loads(out)
+        v1_data = instance_data.get('v1', {})
+        self.assertEqual(
+            ['ds/user-data', 'ds/vendor-data'],
+            instance_data['base64-encoded-keys'])
+        self.assertEqual('nocloud', v1_data['cloud-name'])
+        self.assertIsNone(
+            v1_data['availability-zone'],
+            'found unexpected lxd availability-zone %s' %
+            v1_data['availability-zone'])
+        self.assertIn('cloud-test', v1_data['instance-id'])
+        self.assertIn('cloud-test', v1_data['local-hostname'])
+        self.assertIsNone(
+            v1_data['region'],
+            'found unexpected lxd region %s' % v1_data['region'])
+
+    def test_instance_data_json_kvm(self):
+        """Validate instance-data.json content by nocloud-kvm platform.
+
+        This content is sourced by snapd when determining snapstore endpoints.
+        We validate expected values per cloud type to ensure we don't break
+        snapd.
+        """
+        if self.platform != 'nocloud-kvm':
+            raise base.SkipTest(
+                'Skipping nocloud-kvm instance-data.json on %s' %
+                self.platform)
+        out = self.get_data_file('instance-data.json')
+        if not out:
+            if self.os_name == 'bionic':
+                raise AssertionError('No instance-data.json found on bionic')
+            raise base.SkipTest(
+                'Skipping instance-data.json test. OS: %s not bionic' %
+                self.os_name)
+        instance_data = json.loads(out)
+        v1_data = instance_data.get('v1', {})
+        self.assertEqual(
+            ['ds/user-data'], instance_data['base64-encoded-keys'])
+        self.assertEqual('nocloud', v1_data['cloud-name'])
+        self.assertIsNone(
+            v1_data['availability-zone'],
+            'found unexpected kvm availability-zone %s' %
+            v1_data['availability-zone'])
+        self.assertIsNotNone(
+            re.match('[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}',
+                     v1_data['instance-id']),
+            'kvm instance-id is not a UUID: %s' % v1_data['instance-id'])
+        self.assertIn('ubuntu', v1_data['local-hostname'])
+        self.assertIsNone(
+            v1_data['region'],
+            'found unexpected lxd region %s' % v1_data['region'])
+
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/testcases/modules/snap.yaml b/tests/cloud_tests/testcases/modules/snap.yaml
new file mode 100644
index 0000000..e520415
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/snap.yaml
@@ -0,0 +1,21 @@
+#
+# Install snappy
+#
+required_features:
+  - snap
+cloud_config: |
+  #cloud-config
+  package_update: true
+  snap:
+    squashfuse_in_container: true
+    commands:
+      - snap install hello-world
+collect_scripts:
+  snaplist: |
+    #!/bin/bash
+    snap list
+  instance-data.json: |
+    #!/bin/sh
+    cat /run/cloud-init/instance-data.json
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/verify.py b/tests/cloud_tests/verify.py
index 2a9fd52..5a68a48 100644
--- a/tests/cloud_tests/verify.py
+++ b/tests/cloud_tests/verify.py
@@ -8,13 +8,16 @@ import unittest
 from tests.cloud_tests import (config, LOG, util, testcases)
 
 
-def verify_data(base_dir, tests):
+def verify_data(data_dir, platform, os_name, tests):
     """Verify test data is correct.
 
-    @param base_dir: base directory for data
+    @param data_dir: top level directory for all tests
+    @param platform: The platform name we for this test data (e.g. lxd)
+    @param os_name: The operating system under test (xenial, artful, etc.).
     @param tests: list of test names
     @return_value: {<test_name>: {passed: True/False, failures: []}}
     """
+    base_dir = os.sep.join((data_dir, platform, os_name))
     runner = unittest.TextTestRunner(verbosity=util.current_verbosity())
     res = {}
     for test_name in tests:
@@ -26,7 +29,7 @@ def verify_data(base_dir, tests):
         cloud_conf = test_conf['cloud_config']
 
         # load script outputs
-        data = {}
+        data = {'platform': platform, 'os_name': os_name}
         test_dir = os.path.join(base_dir, test_name)
         for script_name in os.listdir(test_dir):
             with open(os.path.join(test_dir, script_name), 'rb') as fp:
@@ -73,7 +76,7 @@ def verify(args):
 
             # run test
             res[platform][os_name] = verify_data(
-                os.sep.join((args.data_dir, platform, os_name)),
+                args.data_dir, platform, os_name,
                 tests[platform][os_name])
 
             # handle results
diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
index df67a0e..2ac7588 100644
--- a/tests/unittests/test_handler/test_schema.py
+++ b/tests/unittests/test_handler/test_schema.py
@@ -33,6 +33,7 @@ class GetSchemaTest(CiTestCase):
                 'cc_ntp',
                 'cc_resizefs',
                 'cc_runcmd',
+                'cc_snap',
                 'cc_zypper_add_repo'
             ],
             [subschema['id'] for subschema in schema['allOf']])