diff --git a/salt/exceptions.py b/salt/exceptions.py index 9da4a4f8a3..bef8f287fb 100644 --- a/salt/exceptions.py +++ b/salt/exceptions.py @@ -188,6 +188,13 @@ class AuthorizationError(SaltException): ''' +class SaltDaemonNotRunning(SaltException): + ''' + Throw when a running master/minion/syndic is not running but is needed to + perform the requested operation (e.g., eauth). + ''' + + class SaltRunnerError(SaltException): ''' Problem in runner diff --git a/salt/modules/file.py b/salt/modules/file.py index b546e1a3c8..9000746a5a 100644 --- a/salt/modules/file.py +++ b/salt/modules/file.py @@ -3124,7 +3124,9 @@ def get_managed( source_sum = {} if template and source: sfn = __salt__['cp.cache_file'](source, saltenv) - if not os.path.exists(sfn): + # exists doesn't play nice with sfn as bool + # but if cache failed, sfn == False + if not sfn or not os.path.exists(sfn): return sfn, {}, 'Source file {0} not found'.format(source) if sfn == name: raise SaltInvocationError( diff --git a/salt/modules/schedule.py b/salt/modules/schedule.py index 546416c009..8cf9caa97f 100644 --- a/salt/modules/schedule.py +++ b/salt/modules/schedule.py @@ -36,6 +36,8 @@ SCHEDULE_CONF = [ 'splay', 'range', 'when', + 'once', + 'once_fmt', 'returner', 'jid_include', 'args', @@ -263,7 +265,8 @@ def build_schedule_item(name, **kwargs): else: schedule[name]['splay'] = kwargs['splay'] - for item in ['range', 'when', 'cron', 'returner', 'return_config', 'until']: + for item in ['range', 'when', 'once', 'once_fmt', 'cron', 'returner', + 'return_config', 'until']: if item in kwargs: schedule[name][item] = kwargs[item] diff --git a/salt/netapi/__init__.py b/salt/netapi/__init__.py index 4b8b663bad..c11c40174b 100644 --- a/salt/netapi/__init__.py +++ b/salt/netapi/__init__.py @@ -16,7 +16,7 @@ import salt.syspaths import salt.wheel import salt.utils import salt.client.ssh.client -from salt.exceptions import SaltException, EauthAuthenticationError +import salt.exceptions class NetapiClient(object): @@ -31,16 +31,34 @@ class NetapiClient(object): def __init__(self, opts): self.opts = opts + def _is_master_running(self): + ''' + Perform a lightweight check to see if the master daemon is running + + Note, this will return an invalid success if the master crashed or was + not shut down cleanly. + ''' + return os.path.exists(os.path.join( + self.opts['sock_dir'], + 'workers.ipc')) + def run(self, low): ''' Execute the specified function in the specified client by passing the lowstate ''' + # Eauth currently requires a running daemon and commands run through + # this method require eauth so perform a quick check to raise a + # more meaningful error. + if not self._is_master_running(): + raise salt.exceptions.SaltDaemonNotRunning( + 'Salt Master is not available.') + if 'client' not in low: - raise SaltException('No client specified') + raise salt.exceptions.SaltException('No client specified') if not ('token' in low or 'eauth' in low) and low['client'] != 'ssh': - raise EauthAuthenticationError( + raise salt.exceptions.EauthAuthenticationError( 'No authentication credentials given') l_fun = getattr(self, low['client']) diff --git a/salt/netapi/rest_cherrypy/app.py b/salt/netapi/rest_cherrypy/app.py index d2541d9e4f..f102770c99 100644 --- a/salt/netapi/rest_cherrypy/app.py +++ b/salt/netapi/rest_cherrypy/app.py @@ -278,6 +278,41 @@ except ImportError: HAS_WEBSOCKETS = False +def html_override_tool(): + ''' + Bypass the normal handler and serve HTML for all URLs + + The ``app_path`` setting must be non-empty and the request must ask for + ``text/html`` in the ``Accept`` header. + ''' + apiopts = cherrypy.config['apiopts'] + request = cherrypy.request + + url_blacklist = ( + apiopts.get('app_path', '/app'), + apiopts.get('static_path', '/static'), + ) + + if 'app' not in cherrypy.config['apiopts']: + return + + if request.path_info.startswith(url_blacklist): + return + + if request.headers.get('Accept') == '*/*': + return + + try: + wants_html = cherrypy.lib.cptools.accept('text/html') + except cherrypy.HTTPError: + return + else: + if wants_html != 'text/html': + return + + raise cherrypy.InternalRedirect(apiopts.get('app_path', '/app')) + + def salt_token_tool(): ''' If the custom authentication header is supplied, put it in the cookie dict @@ -398,6 +433,9 @@ def hypermedia_handler(*args, **kwargs): except (salt.exceptions.EauthAuthenticationError, salt.exceptions.TokenAuthenticationError): raise cherrypy.HTTPError(401) + except (salt.exceptions.SaltDaemonNotRunning, + salt.exceptions.SaltReqTimeoutError) as exc: + raise cherrypy.HTTPError(503, exc.strerror) except cherrypy.CherryPyException: raise except Exception as exc: @@ -578,6 +616,8 @@ def lowdata_fmt(): cherrypy.serving.request.lowstate = data +cherrypy.tools.html_override = cherrypy.Tool('on_start_resource', + html_override_tool, priority=53) cherrypy.tools.salt_token = cherrypy.Tool('on_start_resource', salt_token_tool, priority=55) cherrypy.tools.salt_auth = cherrypy.Tool('before_request_body', @@ -1365,6 +1405,10 @@ class Login(LowDataAdapter): ] }} ''' + if not self.api._is_master_running(): + raise salt.exceptions.SaltDaemonNotRunning( + 'Salt Master is not available.') + # the urlencoded_processor will wrap this in a list if isinstance(cherrypy.serving.request.lowstate, list): creds = cherrypy.serving.request.lowstate[0] @@ -2216,10 +2260,17 @@ class API(object): 'tools.cpstats.on': self.apiopts.get('collect_stats', False), + 'tools.html_override.on': True, 'tools.cors_tool.on': True, }, } + if 'favicon' in self.apiopts: + conf['/favicon.ico'] = { + 'tools.staticfile.on': True, + 'tools.staticfile.filename': self.apiopts['favicon'], + } + if self.apiopts.get('debug', False) is False: conf['global']['environment'] = 'production' diff --git a/salt/utils/schedule.py b/salt/utils/schedule.py index bb7498efd8..4a7ac394ab 100644 --- a/salt/utils/schedule.py +++ b/salt/utils/schedule.py @@ -85,6 +85,18 @@ localtime. - Thursday 3:00pm - Friday 5:00pm +This will schedule a job to run once on the specified date. The default date +format is ISO 8601 but can be overridden by also specifying the ``once_fmt`` +option. + +.. code-block:: yaml + + schedule: + job1: + function: test.ping + once: 2015-04-22T20:21:00 + once_fmt: '%Y-%m-%dT%H:%M:%S' + This will schedule the command: state.sls httpd test=True at 5pm on Monday, Wednesday and Friday, and 3pm on Tuesday and Thursday. @@ -230,6 +242,7 @@ from __future__ import absolute_import import os import time import datetime +import itertools import multiprocessing import threading import sys @@ -650,7 +663,6 @@ class Schedule(object): seconds = 0 cron = 0 now = int(time.time()) - time_conflict = False if 'until' in data: if not _WHEN_SUPPORTED: @@ -665,30 +677,54 @@ class Schedule(object): 'skipping job: {0}.'.format(data['name'])) continue - for item in ['seconds', 'minutes', 'hours', 'days']: - if item in data and 'when' in data: - time_conflict = True - if item in data and 'cron' in data: - time_conflict = True + # Used for quick lookups when detecting invalid option combinations. + schedule_keys = set(data.keys()) - if time_conflict: - log.error('Unable to use "seconds", "minutes",' - '"hours", or "days" with ' - '"when" or "cron" options. Ignoring.') + time_elements = ('seconds', 'minutes', 'hours', 'days') + scheduling_elements = ('when', 'cron', 'once') + + invalid_sched_combos = [set(i) + for i in itertools.combinations(scheduling_elements, 2)] + + if any(i <= schedule_keys for i in invalid_sched_combos): + log.error('Unable to use "{0}" options together. Ignoring.' + .format('", "'.join(scheduling_elements))) continue - if 'when' in data and 'cron' in data: - log.error('Unable to use "when" and "cron" options together.' - 'Ignoring.') + invalid_time_combos = [] + for item in scheduling_elements: + all_items = itertools.chain([item], time_elements) + invalid_time_combos.append( + set(itertools.combinations(all_items, 2))) + + if any(set(x) <= schedule_keys for x in invalid_time_combos): + log.error('Unable to use "{0}" with "{1}" options. Ignoring' + .format('", "'.join(time_elements), + '", "'.join(scheduling_elements))) continue - time_elements = ['seconds', 'minutes', 'hours', 'days'] if True in [True for item in time_elements if item in data]: # Add up how many seconds between now and then seconds += int(data.get('seconds', 0)) seconds += int(data.get('minutes', 0)) * 60 seconds += int(data.get('hours', 0)) * 3600 seconds += int(data.get('days', 0)) * 86400 + elif 'once' in data: + once_fmt = data.get('once_fmt', '%Y-%m-%dT%H:%M:%S') + + try: + once = datetime.datetime.strptime(data['once'], once_fmt) + once = int(time.mktime(once.timetuple())) + except (TypeError, ValueError): + log.error('Date string could not be parsed: %s, %s', + data['once'], once_fmt) + continue + + if now != once: + continue + else: + seconds = 1 + elif 'when' in data: if not _WHEN_SUPPORTED: log.error('Missing python-dateutil.' diff --git a/tests/unit/modules/qemu_img_test.py b/tests/unit/modules/qemu_img_test.py new file mode 100644 index 0000000000..ae1541e9bc --- /dev/null +++ b/tests/unit/modules/qemu_img_test.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +''' + :codeauthor: :email:`Rupesh Tare ` +''' + +# Import Python Libs +from __future__ import absolute_import + +# Import Salt Testing Libs +from salttesting import TestCase, skipIf +from salttesting.mock import ( + MagicMock, + patch, + NO_MOCK, + NO_MOCK_REASON +) + +from salttesting.helpers import ensure_in_syspath + +ensure_in_syspath('../../') + +# Import Salt Libs +from salt.modules import qemu_img +import os + +# Globals +qemu_img.__salt__ = {} + + +@skipIf(NO_MOCK, NO_MOCK_REASON) +class QemuimgTestCase(TestCase): + ''' + Test cases for salt.modules.qemu_img + ''' + def test_make_image(self): + ''' + Test for create a blank virtual machine image file + of the specified size in megabytes + ''' + with patch.object(os.path, 'isabs', + MagicMock(side_effect=[False, True, True, True])): + self.assertEqual(qemu_img.make_image('location', 'size', 'fmt'), '') + + with patch.object(os.path, 'isdir', + MagicMock(side_effect=[False, True, True])): + self.assertEqual(qemu_img.make_image('location', 'size', 'fmt'), + '') + + with patch.dict(qemu_img.__salt__, + {'cmd.retcode': MagicMock(side_effect=[False, + True])}): + self.assertEqual(qemu_img.make_image('location', 'size', + 'fmt'), 'location') + + self.assertEqual(qemu_img.make_image('location', 'size', + 'fmt'), '') + + +if __name__ == '__main__': + from integration import run_tests + run_tests(QemuimgTestCase, needs_daemon=False) diff --git a/tests/unit/states/apache_test.py b/tests/unit/states/apache_test.py index 8f96e2706c..cc652da972 100644 --- a/tests/unit/states/apache_test.py +++ b/tests/unit/states/apache_test.py @@ -39,28 +39,34 @@ class ApacheTestCase(TestCase): Test to allows for inputting a yaml dictionary into a file for apache configuration files. ''' - name = 'yaml' + name = '/etc/distro/specific/apache.conf' config = 'VirtualHost: this: "*:80"' + new_config = 'LiteralHost: that: "*:79"' ret = {'name': name, 'result': True, 'changes': {}, 'comment': ''} - mock = MagicMock(side_effect=[config, '', '']) with patch.object(salt.utils, 'fopen', mock_open(read_data=config)): - with patch.dict(apache.__salt__, - {'apache.config': mock}): + mock_config = MagicMock(return_value=config) + with patch.dict(apache.__salt__, {'apache.config': mock_config}): ret.update({'comment': 'Configuration is up to date.'}) self.assertDictEqual(apache.configfile(name, config), ret) + with patch.object(salt.utils, 'fopen', mock_open(read_data=config)): + mock_config = MagicMock(return_value=new_config) + with patch.dict(apache.__salt__, {'apache.config': mock_config}): ret.update({'comment': 'Configuration will update.', - 'changes': {'new': '', - 'old': 'VirtualHost: this: "*:80"'}, + 'changes': {'new': new_config, + 'old': config}, 'result': None}) with patch.dict(apache.__opts__, {'test': True}): - self.assertDictEqual(apache.configfile(name, config), ret) + self.assertDictEqual(apache.configfile(name, new_config), ret) + with patch.object(salt.utils, 'fopen', mock_open(read_data=config)): + mock_config = MagicMock(return_value=new_config) + with patch.dict(apache.__salt__, {'apache.config': mock_config}): ret.update({'comment': 'Successfully created configuration.', 'result': True}) with patch.dict(apache.__opts__, {'test': False}): diff --git a/tests/unit/states/boto_elasticache_test.py b/tests/unit/states/boto_elasticache_test.py new file mode 100644 index 0000000000..ed6d52bdc3 --- /dev/null +++ b/tests/unit/states/boto_elasticache_test.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +''' + :codeauthor: :email:`Jayesh Kariya ` +''' +# Import Python libs +from __future__ import absolute_import + +# Import Salt Testing Libs +from salttesting import skipIf, TestCase +from salttesting.mock import ( + NO_MOCK, + NO_MOCK_REASON, + MagicMock, + patch) + +from salttesting.helpers import ensure_in_syspath + +ensure_in_syspath('../../') + +# Import Salt Libs +from salt.states import boto_elasticache + +boto_elasticache.__salt__ = {} +boto_elasticache.__opts__ = {} + + +@skipIf(NO_MOCK, NO_MOCK_REASON) +class BotoElasticacheTestCase(TestCase): + ''' + Test cases for salt.states.boto_elasticache + ''' + # 'present' function tests: 1 + + def test_present(self): + ''' + Test to ensure the cache cluster exists. + ''' + name = 'myelasticache' + engine = 'redis' + cache_node_type = 'cache.t1.micro' + + ret = {'name': name, + 'result': None, + 'changes': {}, + 'comment': ''} + + mock = MagicMock(side_effect=[None, False, False, True]) + mock_bool = MagicMock(return_value=False) + with patch.dict(boto_elasticache.__salt__, + {'boto_elasticache.get_config': mock, + 'boto_elasticache.create': mock_bool}): + comt = ('Failed to retrieve cache cluster info from AWS.') + ret.update({'comment': comt}) + self.assertDictEqual(boto_elasticache.present(name, engine, + cache_node_type), ret) + + with patch.dict(boto_elasticache.__opts__, {'test': True}): + comt = ('Cache cluster {0} is set to be created.'.format(name)) + ret.update({'comment': comt}) + self.assertDictEqual(boto_elasticache.present(name, engine, + cache_node_type), + ret) + + with patch.dict(boto_elasticache.__opts__, {'test': False}): + comt = ('Failed to create {0} cache cluster.'.format(name)) + ret.update({'comment': comt, 'result': False}) + self.assertDictEqual(boto_elasticache.present(name, engine, + cache_node_type), + ret) + + comt = ('Cache cluster {0} is present.'.format(name)) + ret.update({'comment': comt, 'result': True}) + self.assertDictEqual(boto_elasticache.present(name, engine, + cache_node_type), + ret) + + # 'absent' function tests: 1 + + def test_absent(self): + ''' + Test to ensure the named elasticache cluster is deleted. + ''' + name = 'new_table' + + ret = {'name': name, + 'result': True, + 'changes': {}, + 'comment': ''} + + mock = MagicMock(side_effect=[False, True]) + with patch.dict(boto_elasticache.__salt__, + {'boto_elasticache.exists': mock}): + comt = ('{0} does not exist in None.'.format(name)) + ret.update({'comment': comt}) + self.assertDictEqual(boto_elasticache.absent(name), ret) + + with patch.dict(boto_elasticache.__opts__, {'test': True}): + comt = ('Cache cluster {0} is set to be removed.'.format(name)) + ret.update({'comment': comt, 'result': None}) + self.assertDictEqual(boto_elasticache.absent(name), ret) + + +if __name__ == '__main__': + from integration import run_tests + run_tests(BotoElasticacheTestCase, needs_daemon=False) diff --git a/tests/unit/states/boto_elb_test.py b/tests/unit/states/boto_elb_test.py new file mode 100644 index 0000000000..eb21a741ac --- /dev/null +++ b/tests/unit/states/boto_elb_test.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +''' + :codeauthor: :email:`Jayesh Kariya ` +''' +# Import Python libs +from __future__ import absolute_import +import copy + +# Import Salt Testing Libs +from salttesting import skipIf, TestCase +from salttesting.mock import ( + NO_MOCK, + NO_MOCK_REASON, + MagicMock, + patch) + +from salttesting.helpers import ensure_in_syspath + +ensure_in_syspath('../../') + +# Import Salt Libs +from salt.states import boto_elb + +boto_elb.__salt__ = {} +boto_elb.__opts__ = {} + + +@skipIf(NO_MOCK, NO_MOCK_REASON) +class BotoElbTestCase(TestCase): + ''' + Test cases for salt.states.boto_elb + ''' + # 'present' function tests: 1 + + def test_present(self): + ''' + Test to ensure the IAM role exists. + ''' + name = 'myelb' + listeners = [{'elb_port': 'ELBPORT', 'instance_port': 'PORT', + 'elb_protocol': 'HTTPS', 'certificate': 'A'}] + attributes = {'alarm_actions': ['arn:aws:sns:us-east-1:12345:myalarm'], + 'insufficient_data_actions': [], + 'ok_actions': ['arn:aws:sns:us-east-1:12345:myalarm']} + avail_zones = ['us-east-1a', 'us-east-1c', 'us-east-1d'] + alarms = {'alarm_actions': {'name': name, + 'attributes': {'description': 'A'}}} + + ret = {'name': name, + 'result': False, + 'changes': {}, + 'comment': ''} + ret1 = copy.deepcopy(ret) + + mock = MagicMock(return_value={}) + mock_bool = MagicMock(return_value=False) + with patch.dict(boto_elb.__salt__, + {'config.option': mock, + 'boto_elb.exists': mock_bool, + 'boto_elb.create': mock_bool, + 'boto_elb.get_attributes': mock}): + with patch.dict(boto_elb.__opts__, {'test': False}): + comt = (' Failed to create myelb ELB.') + ret.update({'comment': comt}) + self.assertDictEqual(boto_elb.present + (name, listeners, attributes=attributes, + availability_zones=avail_zones), ret) + + mock = MagicMock(return_value={}) + mock_ret = MagicMock(return_value={'result': {'result': False}}) + comt1 = (' Failed to retrieve health_check for ELB myelb.') + with patch.dict(boto_elb.__salt__, + {'config.option': mock, + 'boto_elb.get_attributes': mock, + 'boto_elb.get_health_check': mock, + 'boto_elb.get_elb_config': mock, + 'state.single': mock_ret}): + with patch.dict(boto_elb.__opts__, {'test': False}): + ret1.update({'result': True}) + mock_elb_present = MagicMock(return_value=ret1) + with patch.object(boto_elb, '_elb_present', mock_elb_present): + comt = (' Failed to retrieve attributes for ELB myelb.') + ret.update({'comment': comt}) + self.assertDictEqual(boto_elb.present + (name, listeners), ret) + + with patch.object(boto_elb, '_attributes_present', + mock_elb_present): + ret.update({'comment': comt1}) + self.assertDictEqual(boto_elb.present + (name, listeners), ret) + + with patch.object(boto_elb, '_health_check_present', + mock_elb_present): + comt = (' Failed to retrieve ELB myelb.') + ret.update({'comment': comt}) + self.assertDictEqual(boto_elb.present + (name, listeners), ret) + + with patch.object(boto_elb, '_cnames_present', + mock_elb_present): + comt = (' ') + ret.update({'comment': comt}) + self.assertDictEqual(boto_elb.present + (name, listeners, + alarms=alarms), ret) + + with patch.object(boto_elb, '_alarms_present', + mock_elb_present): + ret.update({'result': True}) + self.assertDictEqual(boto_elb.present + (name, listeners, + alarms=alarms), ret) + + # 'absent' function tests: 1 + + def test_absent(self): + ''' + Test to ensure the IAM role is deleted. + ''' + name = 'new_table' + + ret = {'name': name, + 'result': True, + 'changes': {}, + 'comment': ''} + + mock = MagicMock(side_effect=[False, True]) + with patch.dict(boto_elb.__salt__, {'boto_elb.exists': mock}): + comt = ('{0} ELB does not exist.'.format(name)) + ret.update({'comment': comt}) + self.assertDictEqual(boto_elb.absent(name), ret) + + with patch.dict(boto_elb.__opts__, {'test': True}): + comt = ('ELB {0} is set to be removed.'.format(name)) + ret.update({'comment': comt, 'result': None}) + self.assertDictEqual(boto_elb.absent(name), ret) + + +if __name__ == '__main__': + from integration import run_tests + run_tests(BotoElbTestCase, needs_daemon=False)