Merge ~oddbloke/cloud-init/+git/cloud-init:feature/driver-enablement into cloud-init:master

Proposed by Dan Watkins
Status: Merged
Approved by: Dan Watkins
Approved revision: 0bdf1979bc30e45ddca71b8638e6b94e785bc302
Merge reported by: Server Team CI bot
Merged at revision: not available
Proposed branch: ~oddbloke/cloud-init/+git/cloud-init:feature/driver-enablement
Merge into: cloud-init:master
Diff against target: 362 lines (+306/-0)
6 files modified
cloudinit/config/cc_ubuntu_drivers.py (+112/-0)
cloudinit/config/tests/test_ubuntu_drivers.py (+174/-0)
cloudinit/util.py (+15/-0)
config/cloud.cfg.tmpl (+3/-0)
doc/rtd/topics/modules.rst (+1/-0)
tests/unittests/test_handler/test_schema.py (+1/-0)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Approve
Ryan Harper Approve
Scott Moser Approve
Review via email: mp+363992@code.launchpad.net

Commit message

Add ubuntu_drivers config module

The ubuntu_drivers config module enables usage of the 'ubuntu-drivers'
command. At this point it only serves as a way of installing NVIDIA
drivers for general purpose graphics processing unit (GPGPU)
functionality.

Also, a small usability improvement to get_cfg_by_path to allow it to
take a string for the key path
  "toplevel/second/mykey"
in addition to the original:
  ("toplevel", "second", "mykey")

To post a comment you must log in.
Revision history for this message
Dan Watkins (oddbloke) wrote :

I have tested this in a lxd container (without NVIDIA hardware available) and in a GCE instance with an NVIDIA GPGPU attached, and it behaves as expected.

(This doesn't include support for selecting versions, but I think it adds enough value without that to be worth landing by itself.)

One open question: this relies on ubuntu-drivers-common behaviour that is only present in disco currently (and is only intended to be SRU'd back as far as bionic); what should we do to avoid this running on Ubuntu systems where it can't possibly work?

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:b2515d0c1e955e2b7ec3cf61aa0d608fb48f66a7
https://jenkins.ubuntu.com/server/job/cloud-init-ci/612/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/612/rebuild

review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:b3f9b49af07505d85dbe17b7226e8222573914c3
https://jenkins.ubuntu.com/server/job/cloud-init-ci/614/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/614/rebuild

review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:9646774cc3c4bd24674087411bda86cc46e7b27d
https://jenkins.ubuntu.com/server/job/cloud-init-ci/615/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/615/rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) :
review: Needs Information
Revision history for this message
Dan Watkins (oddbloke) :
Revision history for this message
Scott Moser (smoser) :
review: Needs Information
Revision history for this message
Scott Moser (smoser) wrote :

i approve with your judgement on the log level applied.

review: Approve
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:c9f9075bc859d0ec3bdad5165a1fa3914d7af130
https://jenkins.ubuntu.com/server/job/cloud-init-ci/642/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/642/rebuild

review: Approve (continuous-integration)
af13b69... by Dan Watkins

Modify log level per smoser's suggestion

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:af13b69f8d00652c95545c336f59aa7e22c786ed
https://jenkins.ubuntu.com/server/job/cloud-init-ci/643/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/643/rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) :
review: Needs Information
e8ec33e... by Dan Watkins

Add explanatory comment

Revision history for this message
Dan Watkins (oddbloke) wrote :

On Mon, Mar 18, 2019 at 03:13:42PM -0000, Ryan Harper wrote:
> > + nv_acc = util.translate_bool(util.get_cfg_by_path(cfg, cfgpath))
>
> Wasn't this going to be get_cfg_option_bool()?

get_cfg_option_bool doesn't support the path syntax we're using here.
(translate_bool is what get_cfg_option_bool uses itself.)

I've pushed up a comment explaning the use, but would be happy to make
more of a change if you would prefer.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:e8ec33e7d07d7be4bdc2fddd3ef164ec13d5824e
https://jenkins.ubuntu.com/server/job/cloud-init-ci/644/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/644/rebuild

review: Approve (continuous-integration)
Revision history for this message
Ryan Harper (raharper) wrote :

I missed that; I just saw the config/by/path. A couple in-line nits.

review: Approve
0bdf197... by Dan Watkins

Update/add test name and docstrings

Revision history for this message
Dan Watkins (oddbloke) wrote :

On Mon, Mar 18, 2019 at 04:02:37PM -0000, Ryan Harper wrote:
> I missed that; I just saw the config/by/path. A couple in-line nits.

Nits addressed, I believe.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:0bdf1979bc30e45ddca71b8638e6b94e785bc302
https://jenkins.ubuntu.com/server/job/cloud-init-ci/646/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/646/rebuild

review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/cc_ubuntu_drivers.py b/cloudinit/config/cc_ubuntu_drivers.py
2new file mode 100644
3index 0000000..91feb60
4--- /dev/null
5+++ b/cloudinit/config/cc_ubuntu_drivers.py
6@@ -0,0 +1,112 @@
7+# This file is part of cloud-init. See LICENSE file for license information.
8+
9+"""Ubuntu Drivers: Interact with third party drivers in Ubuntu."""
10+
11+from textwrap import dedent
12+
13+from cloudinit.config.schema import (
14+ get_schema_doc, validate_cloudconfig_schema)
15+from cloudinit import log as logging
16+from cloudinit.settings import PER_INSTANCE
17+from cloudinit import type_utils
18+from cloudinit import util
19+
20+LOG = logging.getLogger(__name__)
21+
22+frequency = PER_INSTANCE
23+distros = ['ubuntu']
24+schema = {
25+ 'id': 'cc_ubuntu_drivers',
26+ 'name': 'Ubuntu Drivers',
27+ 'title': 'Interact with third party drivers in Ubuntu.',
28+ 'description': dedent("""\
29+ This module interacts with the 'ubuntu-drivers' command to install
30+ third party driver packages."""),
31+ 'distros': distros,
32+ 'examples': [dedent("""\
33+ drivers:
34+ nvidia:
35+ license-accepted: true
36+ """)],
37+ 'frequency': frequency,
38+ 'type': 'object',
39+ 'properties': {
40+ 'drivers': {
41+ 'type': 'object',
42+ 'additionalProperties': False,
43+ 'properties': {
44+ 'nvidia': {
45+ 'type': 'object',
46+ 'additionalProperties': False,
47+ 'required': ['license-accepted'],
48+ 'properties': {
49+ 'license-accepted': {
50+ 'type': 'boolean',
51+ 'description': ("Do you accept the NVIDIA driver"
52+ " license?"),
53+ },
54+ 'version': {
55+ 'type': 'string',
56+ 'description': (
57+ 'The version of the driver to install (e.g.'
58+ ' "390", "410"). Defaults to the latest'
59+ ' version.'),
60+ },
61+ },
62+ },
63+ },
64+ },
65+ },
66+}
67+OLD_UBUNTU_DRIVERS_STDERR_NEEDLE = (
68+ "ubuntu-drivers: error: argument <command>: invalid choice: 'install'")
69+
70+__doc__ = get_schema_doc(schema) # Supplement python help()
71+
72+
73+def install_drivers(cfg, pkg_install_func):
74+ if not isinstance(cfg, dict):
75+ raise TypeError(
76+ "'drivers' config expected dict, found '%s': %s" %
77+ (type_utils.obj_name(cfg), cfg))
78+
79+ cfgpath = 'nvidia/license-accepted'
80+ # Call translate_bool to ensure that we treat string values like "yes" as
81+ # acceptance and _don't_ treat string values like "nah" as acceptance
82+ # because they're True-ish
83+ nv_acc = util.translate_bool(util.get_cfg_by_path(cfg, cfgpath))
84+ if not nv_acc:
85+ LOG.debug("Not installing NVIDIA drivers. %s=%s", cfgpath, nv_acc)
86+ return
87+
88+ if not util.which('ubuntu-drivers'):
89+ LOG.debug("'ubuntu-drivers' command not available. "
90+ "Installing ubuntu-drivers-common")
91+ pkg_install_func(['ubuntu-drivers-common'])
92+
93+ driver_arg = 'nvidia'
94+ version_cfg = util.get_cfg_by_path(cfg, 'nvidia/version')
95+ if version_cfg:
96+ driver_arg += ':{}'.format(version_cfg)
97+
98+ LOG.debug("Installing NVIDIA drivers (%s=%s, version=%s)",
99+ cfgpath, nv_acc, version_cfg if version_cfg else 'latest')
100+
101+ try:
102+ util.subp(['ubuntu-drivers', 'install', '--gpgpu', driver_arg])
103+ except util.ProcessExecutionError as exc:
104+ if OLD_UBUNTU_DRIVERS_STDERR_NEEDLE in exc.stderr:
105+ LOG.warning('the available version of ubuntu-drivers is'
106+ ' too old to perform requested driver installation')
107+ elif 'No drivers found for installation.' in exc.stdout:
108+ LOG.warning('ubuntu-drivers found no drivers for installation')
109+ raise
110+
111+
112+def handle(name, cfg, cloud, log, _args):
113+ if "drivers" not in cfg:
114+ log.debug("Skipping module named %s, no 'drivers' key in config", name)
115+ return
116+
117+ validate_cloudconfig_schema(cfg, schema)
118+ install_drivers(cfg['drivers'], cloud.distro.install_packages)
119diff --git a/cloudinit/config/tests/test_ubuntu_drivers.py b/cloudinit/config/tests/test_ubuntu_drivers.py
120new file mode 100644
121index 0000000..efba4ce
122--- /dev/null
123+++ b/cloudinit/config/tests/test_ubuntu_drivers.py
124@@ -0,0 +1,174 @@
125+# This file is part of cloud-init. See LICENSE file for license information.
126+
127+import copy
128+
129+from cloudinit.tests.helpers import CiTestCase, skipUnlessJsonSchema, mock
130+from cloudinit.config.schema import (
131+ SchemaValidationError, validate_cloudconfig_schema)
132+from cloudinit.config import cc_ubuntu_drivers as drivers
133+from cloudinit.util import ProcessExecutionError
134+
135+MPATH = "cloudinit.config.cc_ubuntu_drivers."
136+OLD_UBUNTU_DRIVERS_ERROR_STDERR = (
137+ "ubuntu-drivers: error: argument <command>: invalid choice: 'install' "
138+ "(choose from 'list', 'autoinstall', 'devices', 'debug')\n")
139+
140+
141+class TestUbuntuDrivers(CiTestCase):
142+ cfg_accepted = {'drivers': {'nvidia': {'license-accepted': True}}}
143+ install_gpgpu = ['ubuntu-drivers', 'install', '--gpgpu', 'nvidia']
144+
145+ with_logs = True
146+
147+ @skipUnlessJsonSchema()
148+ def test_schema_requires_boolean_for_license_accepted(self):
149+ with self.assertRaisesRegex(
150+ SchemaValidationError, ".*license-accepted.*TRUE.*boolean"):
151+ validate_cloudconfig_schema(
152+ {'drivers': {'nvidia': {'license-accepted': "TRUE"}}},
153+ schema=drivers.schema, strict=True)
154+
155+ @mock.patch(MPATH + "util.subp", return_value=('', ''))
156+ @mock.patch(MPATH + "util.which", return_value=False)
157+ def _assert_happy_path_taken(self, config, m_which, m_subp):
158+ """Positive path test through handle. Package should be installed."""
159+ myCloud = mock.MagicMock()
160+ drivers.handle('ubuntu_drivers', config, myCloud, None, None)
161+ self.assertEqual([mock.call(['ubuntu-drivers-common'])],
162+ myCloud.distro.install_packages.call_args_list)
163+ self.assertEqual([mock.call(self.install_gpgpu)],
164+ m_subp.call_args_list)
165+
166+ def test_handle_does_package_install(self):
167+ self._assert_happy_path_taken(self.cfg_accepted)
168+
169+ def test_trueish_strings_are_considered_approval(self):
170+ for true_value in ['yes', 'true', 'on', '1']:
171+ new_config = copy.deepcopy(self.cfg_accepted)
172+ new_config['drivers']['nvidia']['license-accepted'] = true_value
173+ self._assert_happy_path_taken(new_config)
174+
175+ @mock.patch(MPATH + "util.subp", side_effect=ProcessExecutionError(
176+ stdout='No drivers found for installation.\n', exit_code=1))
177+ @mock.patch(MPATH + "util.which", return_value=False)
178+ def test_handle_raises_error_if_no_drivers_found(self, m_which, m_subp):
179+ """If ubuntu-drivers doesn't install any drivers, raise an error."""
180+ myCloud = mock.MagicMock()
181+ with self.assertRaises(Exception):
182+ drivers.handle(
183+ 'ubuntu_drivers', self.cfg_accepted, myCloud, None, None)
184+ self.assertEqual([mock.call(['ubuntu-drivers-common'])],
185+ myCloud.distro.install_packages.call_args_list)
186+ self.assertEqual([mock.call(self.install_gpgpu)],
187+ m_subp.call_args_list)
188+ self.assertIn('ubuntu-drivers found no drivers for installation',
189+ self.logs.getvalue())
190+
191+ @mock.patch(MPATH + "util.subp", return_value=('', ''))
192+ @mock.patch(MPATH + "util.which", return_value=False)
193+ def _assert_inert_with_config(self, config, m_which, m_subp):
194+ """Helper to reduce repetition when testing negative cases"""
195+ myCloud = mock.MagicMock()
196+ drivers.handle('ubuntu_drivers', config, myCloud, None, None)
197+ self.assertEqual(0, myCloud.distro.install_packages.call_count)
198+ self.assertEqual(0, m_subp.call_count)
199+
200+ def test_handle_inert_if_license_not_accepted(self):
201+ """Ensure we don't do anything if the license is rejected."""
202+ self._assert_inert_with_config(
203+ {'drivers': {'nvidia': {'license-accepted': False}}})
204+
205+ def test_handle_inert_if_garbage_in_license_field(self):
206+ """Ensure we don't do anything if unknown text is in license field."""
207+ self._assert_inert_with_config(
208+ {'drivers': {'nvidia': {'license-accepted': 'garbage'}}})
209+
210+ def test_handle_inert_if_no_license_key(self):
211+ """Ensure we don't do anything if no license key."""
212+ self._assert_inert_with_config({'drivers': {'nvidia': {}}})
213+
214+ def test_handle_inert_if_no_nvidia_key(self):
215+ """Ensure we don't do anything if other license accepted."""
216+ self._assert_inert_with_config(
217+ {'drivers': {'acme': {'license-accepted': True}}})
218+
219+ def test_handle_inert_if_string_given(self):
220+ """Ensure we don't do anything if string refusal given."""
221+ for false_value in ['no', 'false', 'off', '0']:
222+ self._assert_inert_with_config(
223+ {'drivers': {'nvidia': {'license-accepted': false_value}}})
224+
225+ @mock.patch(MPATH + "install_drivers")
226+ def test_handle_no_drivers_does_nothing(self, m_install_drivers):
227+ """If no 'drivers' key in the config, nothing should be done."""
228+ myCloud = mock.MagicMock()
229+ myLog = mock.MagicMock()
230+ drivers.handle('ubuntu_drivers', {'foo': 'bzr'}, myCloud, myLog, None)
231+ self.assertIn('Skipping module named',
232+ myLog.debug.call_args_list[0][0][0])
233+ self.assertEqual(0, m_install_drivers.call_count)
234+
235+ @mock.patch(MPATH + "util.subp", return_value=('', ''))
236+ @mock.patch(MPATH + "util.which", return_value=True)
237+ def test_install_drivers_no_install_if_present(self, m_which, m_subp):
238+ """If 'ubuntu-drivers' is present, no package install should occur."""
239+ pkg_install = mock.MagicMock()
240+ drivers.install_drivers(self.cfg_accepted['drivers'],
241+ pkg_install_func=pkg_install)
242+ self.assertEqual(0, pkg_install.call_count)
243+ self.assertEqual([mock.call('ubuntu-drivers')],
244+ m_which.call_args_list)
245+ self.assertEqual([mock.call(self.install_gpgpu)],
246+ m_subp.call_args_list)
247+
248+ def test_install_drivers_rejects_invalid_config(self):
249+ """install_drivers should raise TypeError if not given a config dict"""
250+ pkg_install = mock.MagicMock()
251+ with self.assertRaisesRegex(TypeError, ".*expected dict.*"):
252+ drivers.install_drivers("mystring", pkg_install_func=pkg_install)
253+ self.assertEqual(0, pkg_install.call_count)
254+
255+ @mock.patch(MPATH + "util.subp", side_effect=ProcessExecutionError(
256+ stderr=OLD_UBUNTU_DRIVERS_ERROR_STDERR, exit_code=2))
257+ @mock.patch(MPATH + "util.which", return_value=False)
258+ def test_install_drivers_handles_old_ubuntu_drivers_gracefully(
259+ self, m_which, m_subp):
260+ """Older ubuntu-drivers versions should emit message and raise error"""
261+ myCloud = mock.MagicMock()
262+ with self.assertRaises(Exception):
263+ drivers.handle(
264+ 'ubuntu_drivers', self.cfg_accepted, myCloud, None, None)
265+ self.assertEqual([mock.call(['ubuntu-drivers-common'])],
266+ myCloud.distro.install_packages.call_args_list)
267+ self.assertEqual([mock.call(self.install_gpgpu)],
268+ m_subp.call_args_list)
269+ self.assertIn('WARNING: the available version of ubuntu-drivers is'
270+ ' too old to perform requested driver installation',
271+ self.logs.getvalue())
272+
273+
274+# Sub-class TestUbuntuDrivers to run the same test cases, but with a version
275+class TestUbuntuDriversWithVersion(TestUbuntuDrivers):
276+ cfg_accepted = {
277+ 'drivers': {'nvidia': {'license-accepted': True, 'version': '123'}}}
278+ install_gpgpu = ['ubuntu-drivers', 'install', '--gpgpu', 'nvidia:123']
279+
280+ @mock.patch(MPATH + "util.subp", return_value=('', ''))
281+ @mock.patch(MPATH + "util.which", return_value=False)
282+ def test_version_none_uses_latest(self, m_which, m_subp):
283+ myCloud = mock.MagicMock()
284+ version_none_cfg = {
285+ 'drivers': {'nvidia': {'license-accepted': True, 'version': None}}}
286+ drivers.handle(
287+ 'ubuntu_drivers', version_none_cfg, myCloud, None, None)
288+ self.assertEqual(
289+ [mock.call(['ubuntu-drivers', 'install', '--gpgpu', 'nvidia'])],
290+ m_subp.call_args_list)
291+
292+ def test_specifying_a_version_doesnt_override_license_acceptance(self):
293+ self._assert_inert_with_config({
294+ 'drivers': {'nvidia': {'license-accepted': False,
295+ 'version': '123'}}
296+ })
297+
298+# vi: ts=4 expandtab
299diff --git a/cloudinit/util.py b/cloudinit/util.py
300index a192091..385f231 100644
301--- a/cloudinit/util.py
302+++ b/cloudinit/util.py
303@@ -703,6 +703,21 @@ def get_cfg_option_list(yobj, key, default=None):
304 # get a cfg entry by its path array
305 # for f['a']['b']: get_cfg_by_path(mycfg,('a','b'))
306 def get_cfg_by_path(yobj, keyp, default=None):
307+ """Return the value of the item at path C{keyp} in C{yobj}.
308+
309+ example:
310+ get_cfg_by_path({'a': {'b': {'num': 4}}}, 'a/b/num') == 4
311+ get_cfg_by_path({'a': {'b': {'num': 4}}}, 'c/d') == None
312+
313+ @param yobj: A dictionary.
314+ @param keyp: A path inside yobj. it can be a '/' delimited string,
315+ or an iterable.
316+ @param default: The default to return if the path does not exist.
317+ @return: The value of the item at keyp."
318+ is not found."""
319+
320+ if isinstance(keyp, six.string_types):
321+ keyp = keyp.split("/")
322 cur = yobj
323 for tok in keyp:
324 if tok not in cur:
325diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
326index 7513176..25db43e 100644
327--- a/config/cloud.cfg.tmpl
328+++ b/config/cloud.cfg.tmpl
329@@ -112,6 +112,9 @@ cloud_final_modules:
330 - landscape
331 - lxd
332 {% endif %}
333+{% if variant in ["ubuntu", "unknown"] %}
334+ - ubuntu-drivers
335+{% endif %}
336 {% if variant not in ["freebsd"] %}
337 - puppet
338 - chef
339diff --git a/doc/rtd/topics/modules.rst b/doc/rtd/topics/modules.rst
340index d9720f6..3dcdd3b 100644
341--- a/doc/rtd/topics/modules.rst
342+++ b/doc/rtd/topics/modules.rst
343@@ -54,6 +54,7 @@ Modules
344 .. automodule:: cloudinit.config.cc_ssh_import_id
345 .. automodule:: cloudinit.config.cc_timezone
346 .. automodule:: cloudinit.config.cc_ubuntu_advantage
347+.. automodule:: cloudinit.config.cc_ubuntu_drivers
348 .. automodule:: cloudinit.config.cc_update_etc_hosts
349 .. automodule:: cloudinit.config.cc_update_hostname
350 .. automodule:: cloudinit.config.cc_users_groups
351diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
352index 1bad07f..e69a47a 100644
353--- a/tests/unittests/test_handler/test_schema.py
354+++ b/tests/unittests/test_handler/test_schema.py
355@@ -28,6 +28,7 @@ class GetSchemaTest(CiTestCase):
356 'cc_runcmd',
357 'cc_snap',
358 'cc_ubuntu_advantage',
359+ 'cc_ubuntu_drivers',
360 'cc_zypper_add_repo'
361 ],
362 [subschema['id'] for subschema in schema['allOf']])

Subscribers

People subscribed via source and target branches