Merge pull request #28824 from rallytime/bp-28778-and-28820

Back-port #28778 and #28820 to 2015.8
This commit is contained in:
Mike Place 2015-11-12 11:06:31 -07:00
commit 08891cb210
10 changed files with 1222 additions and 0 deletions

37
salt/grains/chronos.py Normal file
View File

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
'''
Generate chronos proxy minion grains.
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import salt.utils
import salt.utils.http
__proxyenabled__ = ['chronos']
__virtualname__ = 'chronos'
def __virtual__():
if not salt.utils.is_proxy() or 'proxy' not in __opts__:
return False
else:
return __virtualname__
def kernel():
return {'kernel': 'chronos'}
def os():
return {'os': 'chronos'}
def os_family():
return {'os_family': 'chronos'}
def os_data():
return {'os_data': 'chronos'}

50
salt/grains/marathon.py Normal file
View File

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
'''
Generate marathon proxy minion grains.
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import salt.utils
import salt.utils.http
__proxyenabled__ = ['marathon']
__virtualname__ = 'marathon'
def __virtual__():
if not salt.utils.is_proxy() or 'proxy' not in __opts__:
return False
else:
return __virtualname__
def kernel():
return {'kernel': 'marathon'}
def os():
return {'os': 'marathon'}
def os_family():
return {'os_family': 'marathon'}
def os_data():
return {'os_data': 'marathon'}
def marathon():
response = salt.utils.http.query(
"{0}/v2/info".format(__opts__['proxy'].get(
'base_url',
"http://locahost:8080",
)),
decode_type='json',
decode=True,
)
if not response or 'dict' not in response:
return {'marathon': None}
return {'marathon': response['dict']}

132
salt/modules/chronos.py Normal file
View File

@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
'''
Module providing a simple management interface to a chronos cluster.
Currently this only works when run through a proxy minion.
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import json
import logging
import salt.utils
import salt.utils.http
__proxyenabled__ = ['chronos']
log = logging.getLogger(__file__)
def __virtual__():
# only valid in proxy minions for now
return salt.utils.is_proxy() and 'proxy' in __opts__
def _base_url():
'''
Return the proxy configured base url.
'''
base_url = "http://locahost:4400"
if 'proxy' in __opts__:
base_url = __opts__['proxy'].get('base_url', base_url)
return base_url
def _jobs():
'''
Return the currently configured jobs.
'''
response = salt.utils.http.query(
"{0}/scheduler/jobs".format(_base_url()),
decode_type='json',
decode=True,
)
jobs = {}
for job in response['dict']:
jobs[job.pop('name')] = job
return jobs
def jobs():
'''
Return a list of the currently installed job names.
CLI Example:
.. code-block:: bash
salt chronos-minion-id chronos.jobs
'''
job_names = _jobs().keys()
job_names.sort()
return {'jobs': job_names}
def has_job(name):
'''
Return whether the given job is currently configured.
CLI Example:
.. code-block:: bash
salt chronos-minion-id chronos.has_job my-job
'''
return name in _jobs()
def job(name):
'''
Return the current server configuration for the specified job.
CLI Example:
.. code-block:: bash
salt chronos-minion-id chronos.job my-job
'''
jobs = _jobs()
if name in jobs:
return {'job': jobs[name]}
return None
def update_job(name, config):
'''
Update the specified job with the given configuration.
CLI Example:
.. code-block:: bash
salt chronos-minion-id chronos.update_job my-job '<config yaml>'
'''
if 'name' not in config:
config['name'] = name
data = json.dumps(config)
try:
response = salt.utils.http.query(
"{0}/scheduler/iso8601".format(_base_url()),
method='POST',
data=data,
header_dict={
'Content-Type': 'application/json',
},
)
log.debug('update response: %s', response)
return {'success': True}
except Exception as ex:
log.error('unable to update chronos job: %s', ex.message)
return {
'exception': {
'message': ex.message,
}
}
def rm_job(name):
'''
Remove the specified job from the server.
CLI Example:
.. code-block:: bash
salt chronos-minion-id chronos.rm_job my-job
'''
response = salt.utils.http.query(
"{0}/scheduler/job/{1}".format(_base_url(), name),
method='DELETE',
)
return True

153
salt/modules/marathon.py Normal file
View File

@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
'''
Module providing a simple management interface to a marathon cluster.
Currently this only works when run through a proxy minion.
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import json
import logging
import salt.utils
import salt.utils.http
__proxyenabled__ = ['marathon']
log = logging.getLogger(__file__)
def __virtual__():
# only valid in proxy minions for now
return salt.utils.is_proxy() and 'proxy' in __opts__
def _base_url():
'''
Return the proxy configured base url.
'''
base_url = "http://locahost:8080"
if 'proxy' in __opts__:
base_url = __opts__['proxy'].get('base_url', base_url)
return base_url
def _app_id(app_id):
'''
Make sure the app_id is in the correct format.
'''
if app_id[0] != '/':
app_id = '/{0}'.format(app_id)
return app_id
def apps():
'''
Return a list of the currently installed app ids.
CLI Example:
.. code-block:: bash
salt marathon-minion-id marathon.apps
'''
response = salt.utils.http.query(
"{0}/v2/apps".format(_base_url()),
decode_type='json',
decode=True,
)
return {'apps': [app['id'] for app in response['dict']['apps']]}
def has_app(id):
'''
Return whether the given app id is currently configured.
CLI Example:
.. code-block:: bash
salt marathon-minion-id marathon.has_app my-app
'''
return _app_id(id) in apps()['apps']
def app(id):
'''
Return the current server configuration for the specified app.
CLI Example:
.. code-block:: bash
salt marathon-minion-id marathon.app my-app
'''
response = salt.utils.http.query(
"{0}/v2/apps/{1}".format(_base_url(), id),
decode_type='json',
decode=True,
)
return response['dict']
def update_app(id, config):
'''
Update the specified app with the given configuration.
CLI Example:
.. code-block:: bash
salt marathon-minion-id marathon.update_app my-app '<config yaml>'
'''
if 'id' not in config:
config['id'] = id
config.pop('version', None)
data = json.dumps(config)
try:
response = salt.utils.http.query(
"{0}/v2/apps/{1}?force=true".format(_base_url(), id),
method='PUT',
decode_type='json',
decode=True,
data=data,
header_dict={
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)
log.debug('update response: %s', response)
return response['dict']
except Exception as ex:
log.error('unable to update marathon app: %s', ex.message)
return {
'exception': {
'message': ex.message,
}
}
def rm_app(id):
'''
Remove the specified app from the server.
CLI Example:
.. code-block:: bash
salt marathon-minion-id marathon.rm_app my-app
'''
response = salt.utils.http.query(
"{0}/v2/apps/{1}".format(_base_url(), id),
method='DELETE',
decode_type='json',
decode=True,
)
return response['dict']
def info():
'''
Return configuration and status information about the marathon instance.
CLI Example:
.. code-block:: bash
salt marathon-minion-id marathon.info
'''
response = salt.utils.http.query(
"{0}/v2/info".format(_base_url()),
decode_type='json',
decode=True,
)
return response['dict']

83
salt/proxy/chronos.py Normal file
View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
'''
Chronos
========
Proxy minion for managing a Chronos cluster.
Dependencies
------------
- :doc:`chronos execution module (salt.modules.chronos) </ref/modules/all/salt.modules.chronos>`
Pillar
------
The chronos proxy configuration requires a 'base_url' property that points to
the chronos endpoint:
.. code-block:: yaml
proxy:
proxytype: chronos
base_url: http://my-chronos-master.mydomain.com:4400
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import logging
import salt.utils.http
__proxyenabled__ = ['chronos']
CONFIG = {}
CONFIG_BASE_URL = 'base_url'
log = logging.getLogger(__file__)
def __virtual__():
return True
def init(opts):
'''
Perform any needed setup.
'''
if CONFIG_BASE_URL in opts['proxy']:
CONFIG[CONFIG_BASE_URL] = opts['proxy'][CONFIG_BASE_URL]
else:
log.error('missing proxy property %s', CONFIG_BASE_URL)
log.debug('CONFIG: %s', CONFIG)
def ping():
'''
Is the chronos api responding?
'''
try:
response = salt.utils.http.query(
"{0}/scheduler/jobs".format(CONFIG[CONFIG_BASE_URL]),
decode_type='json',
decode=True,
)
log.debug(
'chronos.info returned succesfully: %s',
response,
)
if 'dict' in response:
return True
except Exception as ex:
log.error(
'error pinging chronos with base_url %s: %s',
CONFIG[CONFIG_BASE_URL],
ex,
)
return False
def shutdown(opts):
'''
For this proxy shutdown is a no-op
'''
log.debug('chronos proxy shutdown() called...')

83
salt/proxy/marathon.py Normal file
View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
'''
Marathon
========
Proxy minion for managing a Marathon cluster.
Dependencies
------------
- :doc:`marathon execution module (salt.modules.marathon) </ref/modules/all/salt.modules.marathon>`
Pillar
------
The marathon proxy configuration requires a 'base_url' property that points to
the marathon endpoint:
.. code-block:: yaml
proxy:
proxytype: marathon
base_url: http://my-marathon-master.mydomain.com:8080
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import logging
import salt.utils.http
__proxyenabled__ = ['marathon']
CONFIG = {}
CONFIG_BASE_URL = 'base_url'
log = logging.getLogger(__file__)
def __virtual__():
return True
def init(opts):
'''
Perform any needed setup.
'''
if CONFIG_BASE_URL in opts['proxy']:
CONFIG[CONFIG_BASE_URL] = opts['proxy'][CONFIG_BASE_URL]
else:
log.error('missing proxy property %s', CONFIG_BASE_URL)
log.debug('CONFIG: %s', CONFIG)
def ping():
'''
Is the marathon api responding?
'''
try:
response = salt.utils.http.query(
"{0}/ping".format(CONFIG[CONFIG_BASE_URL]),
decode_type='plain',
decode=True,
)
log.debug(
'marathon.info returned succesfully: %s',
response,
)
if 'text' in response and response['text'].strip() == 'pong':
return True
except Exception as ex:
log.error(
'error calling marathon.info with base_url %s: %s',
CONFIG[CONFIG_BASE_URL],
ex,
)
return False
def shutdown(opts):
'''
For this proxy shutdown is a no-op
'''
log.debug('marathon proxy shutdown() called...')

138
salt/states/chronos_job.py Normal file
View File

@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
'''
Configure Chronos jobs via a salt proxy.
.. code-block:: yaml
my_job:
chronos_job.config:
- config:
schedule: "R//PT2S"
command: "echo 'hi'"
owner: "me@mycompany.com"
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import copy
import logging
import salt.utils.configcomparer
__proxyenabled__ = ['chronos']
log = logging.getLogger(__file__)
def config(name, config):
'''
Ensure that the chronos job with the given name is present and is configured
to match the given config values.
:param name: The job name
:param config: The configuration to apply (dict)
:return: A standard Salt changes dictionary
'''
# setup return structure
ret = {
'name': name,
'changes': {},
'result': False,
'comment': '',
}
# get existing config if job is present
existing_config = None
if __salt__['chronos.has_job'](name):
existing_config = __salt__['chronos.job'](name)['job']
# compare existing config with defined config
if existing_config:
update_config = copy.deepcopy(existing_config)
salt.utils.configcomparer.compare_and_update_config(
config,
update_config,
ret['changes'],
)
else:
# the job is not configured--we need to create it from scratch
ret['changes']['job'] = {
'new': config,
'old': None,
}
update_config = config
if ret['changes']:
# if the only change is in schedule, check to see if patterns are equivalent
if 'schedule' in ret['changes'] and len(ret['changes']) == 1:
if 'new' in ret['changes']['schedule'] and 'old' in ret['changes']['schedule']:
new = ret['changes']['schedule']['new']
log.debug('new schedule: %s', new)
old = ret['changes']['schedule']['old']
log.debug('old schedule: %s', old)
if new and old:
_new = new.split('/')
log.debug('_new schedule: %s', _new)
_old = old.split('/')
log.debug('_old schedule: %s', _old)
if len(_new) == 3 and len(_old) == 3:
log.debug('_new[0] == _old[0]: %s', str(_new[0]) == str(_old[0]))
log.debug('_new[2] == _old[2]: %s', str(_new[2]) == str(_old[2]))
if str(_new[0]) == str(_old[0]) and str(_new[2]) == str(_old[2]):
log.debug('schedules match--no need for changes')
ret['changes'] = {}
# update the config if we registered any changes
log.debug('schedules match--no need for changes')
if ret['changes']:
# if test report there will be an update
if __opts__['test']:
ret['result'] = None
ret['comment'] = 'Chronos job {0} is set to be updated'.format(
name
)
return ret
update_result = __salt__['chronos.update_job'](name, update_config)
if 'exception' in update_result:
ret['result'] = False
ret['comment'] = 'Failed to update job config for {0}: {1}'.format(
name,
update_result['exception'],
)
return ret
else:
ret['result'] = True
ret['comment'] = 'Updated job config for {0}'.format(name)
return ret
ret['result'] = True
ret['comment'] = 'Chronos job {0} configured correctly'.format(name)
return ret
def absent(name):
'''
Ensure that the chronos job with the given name is not present.
:param name: The app name
:return: A standard Salt changes dictionary
'''
ret = {'name': name,
'changes': {},
'result': False,
'comment': ''}
if not __salt__['chronos.has_job'](name):
ret['result'] = True
ret['comment'] = 'Job {0} already absent'.format(name)
return ret
if __opts__['test']:
ret['result'] = None
ret['comment'] = 'Job {0} is set to be removed'.format(name)
return ret
if __salt__['chronos.rm_job'](name):
ret['changes'] = {'job': name}
ret['result'] = True
ret['comment'] = 'Removed job {0}'.format(name)
return ret
else:
ret['result'] = False
ret['comment'] = 'Failed to remove job {0}'.format(name)
return ret

118
salt/states/marathon_app.py Normal file
View File

@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
'''
Configure Marathon apps via a salt proxy.
.. code-block:: yaml
my_app:
marathon_app.config:
- config:
cmd: "while [ true ] ; do echo 'Hello Marathon' ; sleep 5 ; done"
cpus: 0.1
mem: 10
instances: 3
.. versionadded:: 2015.8.2
'''
from __future__ import absolute_import
import copy
import logging
import salt.utils.configcomparer
__proxyenabled__ = ['marathon']
log = logging.getLogger(__file__)
def config(name, config):
'''
Ensure that the marathon app with the given id is present and is configured
to match the given config values.
:param name: The app name/id
:param config: The configuration to apply (dict)
:return: A standard Salt changes dictionary
'''
# setup return structure
ret = {
'name': name,
'changes': {},
'result': False,
'comment': '',
}
# get existing config if app is present
existing_config = None
if __salt__['marathon.has_app'](name):
existing_config = __salt__['marathon.app'](name)['app']
# compare existing config with defined config
if existing_config:
update_config = copy.deepcopy(existing_config)
salt.utils.configcomparer.compare_and_update_config(
config,
update_config,
ret['changes'],
)
else:
# the app is not configured--we need to create it from scratch
ret['changes']['app'] = {
'new': config,
'old': None,
}
update_config = config
# update the config if we registered any changes
if ret['changes']:
# if test, report there will be an update
if __opts__['test']:
ret['result'] = None
ret['comment'] = 'Marathon app {0} is set to be updated'.format(
name
)
return ret
update_result = __salt__['marathon.update_app'](name, update_config)
if 'exception' in update_result:
ret['result'] = False
ret['comment'] = 'Failed to update app config for {0}: {1}'.format(
name,
update_result['exception'],
)
return ret
else:
ret['result'] = True
ret['comment'] = 'Updated app config for {0}'.format(name)
return ret
ret['result'] = True
ret['comment'] = 'Marathon app {0} configured correctly'.format(name)
return ret
def absent(name):
'''
Ensure that the marathon app with the given id is not present.
:param name: The app name/id
:return: A standard Salt changes dictionary
'''
ret = {'name': name,
'changes': {},
'result': False,
'comment': ''}
if not __salt__['marathon.has_app'](name):
ret['result'] = True
ret['comment'] = 'App {0} already absent'.format(name)
return ret
if __opts__['test']:
ret['result'] = None
ret['comment'] = 'App {0} is set to be removed'.format(name)
return ret
if __salt__['marathon.rm_app'](name):
ret['changes'] = {'app': name}
ret['result'] = True
ret['comment'] = 'Removed app {0}'.format(name)
return ret
else:
ret['result'] = False
ret['comment'] = 'Failed to remove app {0}'.format(name)
return ret

View File

@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""
Utilities for comparing and updating configurations while keeping track of
changes in a way that can be easily reported in a state.
"""
from __future__ import absolute_import
def compare_and_update_config(config, update_config, changes, namespace=''):
'''
Recursively compare two configs, writing any needed changes to the
update_config and capturing changes in the changes dict.
'''
if isinstance(config, dict):
if not update_config:
if config:
# the updated config is more valid--report that we are using it
changes[namespace] = {
'new': config,
'old': update_config,
}
return config
elif not isinstance(update_config, dict):
# new config is a dict, other isn't--new one wins
changes[namespace] = {
'new': config,
'old': update_config,
}
return config
else:
# compare each key in the base config with the values in the
# update_config, overwriting the values that are different but
# keeping any that are not defined in config
for key, value in config.iteritems():
_namespace = key
if namespace:
_namespace = '{0}.{1}'.format(namespace, _namespace)
update_config[key] = compare_and_update_config(
value,
update_config.get(key, None),
changes,
namespace=_namespace,
)
return update_config
elif isinstance(config, list):
if not update_config:
if config:
# the updated config is more valid--report that we are using it
changes[namespace] = {
'new': config,
'old': update_config,
}
return config
elif not isinstance(update_config, list):
# new config is a list, other isn't--new one wins
changes[namespace] = {
'new': config,
'old': update_config,
}
return config
else:
# iterate through config list, ensuring that each index in the
# update_config list is the same
for idx, item in enumerate(config):
_namespace = '[{0}]'.format(idx)
if namespace:
_namespace = '{0}{1}'.format(namespace, _namespace)
_update = None
if len(update_config) > idx:
_update = update_config[idx]
if _update:
update_config[idx] = compare_and_update_config(
config[idx],
_update,
changes,
namespace=_namespace,
)
else:
changes[_namespace] = {
'new': config[idx],
'old': _update,
}
update_config.append(config[idx])
if len(update_config) > len(config):
# trim any items in update_config that are not in config
for idx, old_item in enumerate(update_config):
if idx < len(config):
continue
_namespace = '[{0}]'.format(idx)
if namespace:
_namespace = '{0}{1}'.format(namespace, _namespace)
changes[_namespace] = {
'new': None,
'old': old_item,
}
del update_config[len(config):]
return update_config
else:
if config != update_config:
changes[namespace] = {
'new': config,
'old': update_config,
}
return config

View File

@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
# Import python libs
from __future__ import absolute_import
import copy
# Import Salt Testing libs
from salttesting import TestCase
from salttesting.helpers import ensure_in_syspath
ensure_in_syspath('../../')
# Import Salt libs
from salt.utils import configcomparer
class UtilConfigcomparerTestCase(TestCase):
base_config = {
'attr1': 'value1',
'attr2': [
'item1',
'item2',
'item3',
],
'attr3': [],
'attr4': {},
'attr5': {
'subattr1': 'value1',
'subattr2': [
'item1',
],
},
}
def test_compare_and_update_config(self):
# empty config
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{},
to_update,
changes,
)
self.assertEqual({}, changes)
self.assertEqual(self.base_config, to_update)
# simple, new value
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attrx': 'value1',
},
to_update,
changes,
)
self.assertEqual(
{
'attrx': {
'new': 'value1',
'old': None,
},
},
changes,
)
self.assertEqual('value1', to_update['attrx'])
self.assertEqual('value1', to_update['attr1'])
# simple value
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr1': 'value2',
},
to_update,
changes,
)
self.assertEqual(
{
'attr1': {
'new': 'value2',
'old': 'value1',
},
},
changes,
)
self.assertEqual('value2', to_update['attr1'])
self.assertEqual(
{
'attr1': 'value2',
'attr2': [
'item1',
'item2',
'item3',
],
'attr3': [],
'attr4': {},
'attr5': {
'subattr1': 'value1',
'subattr2': [
'item1',
],
},
},
to_update,
)
# empty list
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr3': [],
},
to_update,
changes,
)
self.assertEqual({}, changes)
self.assertEqual(self.base_config, to_update)
# list value (add)
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr2': [
'item1',
'item2',
'item3',
'item4',
],
},
to_update,
changes,
)
self.assertEqual(
{
'attr2[3]': {
'new': 'item4',
'old': None,
},
},
changes,
)
self.assertEqual(
{
'attr1': 'value1',
'attr2': [
'item1',
'item2',
'item3',
'item4',
],
'attr3': [],
'attr4': {},
'attr5': {
'subattr1': 'value1',
'subattr2': [
'item1',
],
},
},
to_update,
)
# list value (remove and modify)
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr2': [
'itemx',
'item2',
],
},
to_update,
changes,
)
self.assertEqual(
{
'attr2[0]': {
'new': 'itemx',
'old': 'item1',
},
'attr2[2]': {
'new': None,
'old': 'item3',
},
},
changes,
)
self.assertEqual(
{
'attr1': 'value1',
'attr2': [
'itemx',
'item2',
],
'attr3': [],
'attr4': {},
'attr5': {
'subattr1': 'value1',
'subattr2': [
'item1',
],
},
},
to_update,
)
# empty dict
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr4': {}
},
to_update,
changes,
)
self.assertEqual({}, changes)
self.assertEqual(self.base_config, to_update)
# dict value (add)
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr5': {
'subattr3': 'value1',
},
},
to_update,
changes,
)
self.assertEqual(
{
'attr5.subattr3': {
'new': 'value1',
'old': None,
},
},
changes,
)
self.assertEqual(
{
'attr1': 'value1',
'attr2': [
'item1',
'item2',
'item3',
],
'attr3': [],
'attr4': {},
'attr5': {
'subattr1': 'value1',
'subattr2': [
'item1',
],
'subattr3': 'value1',
},
},
to_update,
)
# dict value (remove and modify)
to_update = copy.deepcopy(self.base_config)
changes = {}
configcomparer.compare_and_update_config(
{
'attr5': {
'subattr1': 'value2',
'subattr2': [
'item1',
'item2',
],
},
},
to_update,
changes,
)
self.assertEqual(
{
'attr5.subattr1': {
'new': 'value2',
'old': 'value1',
},
'attr5.subattr2[1]': {
'new': 'item2',
'old': None,
},
},
changes,
)
self.assertEqual(
{
'attr1': 'value1',
'attr2': [
'item1',
'item2',
'item3',
],
'attr3': [],
'attr4': {},
'attr5': {
'subattr1': 'value2',
'subattr2': [
'item1',
'item2',
],
},
},
to_update,
)
if __name__ == '__main__':
from integration import run_tests
run_tests(UtilConfigcomparerTestCase, needs_daemon=False)