Tasklib implementation in python

Medium between configuration providers and fuel orchestration
solution.

check README.md and functional tests for better understanding
how it works

HOW TO RUN:
python setup.py develop

taskcmd -c tasklib/tests/functional/conf.yaml run puppet/sleep

taskcmd -c tasklib/tests/functional/conf.yaml run exec/fail

taskcmd -c tasklib/tests/functional/conf.yaml daemon exec/long
taskcmd -c tasklib/tests/functional/conf.yaml status exec/long

Related to blueprint granular-deployment-based-on-tasks

Change-Id: Ifed1a9b90042bbbc93215a30dfb34e29dbe8fba2
This commit is contained in:
Dima Shulyak 2014-09-02 14:40:51 +03:00
parent d8d2d3ced8
commit 4dd82c6be3
44 changed files with 1331 additions and 0 deletions

View File

@ -23,6 +23,7 @@ function usage {
echo " -a, --agent Run FUEL_AGENT unit tests"
echo " -A, --no-agent Don't run FUEL_AGENT unit tests"
echo " -n, --nailgun Run NAILGUN both unit and integration tests"
echo " -l, --tasklib Run tasklib unit and functional tests"
echo " -N, --no-nailgun Don't run NAILGUN tests"
echo " -w, --webui Run WEB-UI tests"
echo " -W, --no-webui Don't run WEB-UI tests"
@ -53,6 +54,8 @@ function process_options {
-A|--no-agent) no_agent_tests=1;;
-n|--nailgun) nailgun_tests=1;;
-N|--no-nailgun) no_nailgun_tests=1;;
-l|--tasklib) tasklib_tests=1;;
-L|--no-tasklib) no_tasklib_tests=1;;
-w|--webui) webui_tests=1;;
-W|--no-webui) no_webui_tests=1;;
-c|--cli) cli_tests=1;;
@ -115,6 +118,8 @@ no_flake8_checks=0
jslint_checks=0
no_jslint_checks=0
certain_tests=0
tasklib_tests=0
no_tasklib_tests=0
function run_tests {
@ -134,6 +139,7 @@ function run_tests {
# Enable all tests if none was specified skipping all explicitly disabled tests.
if [[ $agent_tests -eq 0 && \
$nailgun_tests -eq 0 && \
$tasklib_tests -eq 0 && \
$webui_tests -eq 0 && \
$cli_tests -eq 0 && \
$upgrade_system -eq 0 && \
@ -143,6 +149,7 @@ function run_tests {
if [ $no_agent_tests -ne 1 ]; then agent_tests=1; fi
if [ $no_nailgun_tests -ne 1 ]; then nailgun_tests=1; fi
if [ $no_tasklib_tests -ne 1 ]; then tasklib_tests=1; fi
if [ $no_webui_tests -ne 1 ]; then webui_tests=1; fi
if [ $no_cli_tests -ne 1 ]; then cli_tests=1; fi
if [ $no_upgrade_system -ne 1 ]; then upgrade_system=1; fi
@ -167,6 +174,11 @@ function run_tests {
run_nailgun_tests || errors+=" nailgun_tests"
fi
if [ $tasklib_tests -eq 1 ]; then
echo "Starting Tasklib tests"
run_tasklib_tests || errors+=" tasklib tests"
fi
if [ $webui_tests -eq 1 ]; then
echo "Starting WebUI tests..."
run_webui_tests || errors+=" webui_tests"
@ -411,6 +423,19 @@ function run_shotgun_tests {
return $result
}
function run_tasklib_tests {
local result=0
pushd $ROOT/tasklib >> /dev/null
# run tests
tox -epy26 || result=1
popd >> /dev/null
return $result
}
function run_flake8_subproject {
local DIRECTORY=$1
local result=0
@ -432,6 +457,7 @@ function run_flake8 {
local result=0
run_flake8_subproject fuel_agent && \
run_flake8_subproject nailgun && \
run_flake8_subproject tasklib && \
run_flake8_subproject fuelclient && \
run_flake8_subproject fuelmenu && \
run_flake8_subproject network_checker && \

5
tasklib/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
tmp/*
*.egg-info
*.pyc
tasklib/tests/functional/tmp/
tasklib.log

1
tasklib/MANIFEST.in Normal file
View File

@ -0,0 +1 @@
include *requirements.txt

72
tasklib/README.md Normal file
View File

@ -0,0 +1,72 @@
Tasklib
=======
Tasklib is mediator between different configuration management providers
and orchestration mechanism in Fuel.
It will try to cover next areas:
- part of the plugable fuel architecture
See tasklib/tasklib/tests/functional for detailed descriptionof
how tasklib plugability will work
- Control mechanism for tasks in fuel
To support different types of workflow we will provide
ability to terminate, list all running, stop/pause task
- Abstraction layer between tasks and orchestration, which will allow
easier development and debuging of tasks
- General reporting solution for tasks
Executions drivers
==================
- puppet
- exec
Puppet
--------
Puppet executor supports general metadata for running puppet manifests.
Example of such metadata (task.yaml):
type: puppet - required
puppet_manifest: file.pp - default is site.pp
puppet_moduels: /etc/puppet/modules
puppet_options: --debug
All defaults you can find in:
>> taskcmd conf
It works next way:
After task.yaml is found - executor will look for puppet_manifest
and run:
puppet apply --modulepath=/etc/puppet/modules file.pp
with additional options you will provide
Exec
-----
type: exec - required
cmd: echo 12 - required
will execute any cmd provided as subprocess
EXAMPLES:
=========
taskcmd -c tasklib/tests/functional/conf.yaml conf
taskcmd -c tasklib/tests/functional/conf.yaml list
taskcmd -c tasklib/tests/functional/conf.yaml daemon puppet/sleep
taskcmd -c tasklib/tests/functional/conf.yaml status puppet/sleep
taskcmd -c tasklib/tests/functional/conf.yaml run puppet/cmd
taskcmd -c tasklib/tests/functional/conf.yaml run puppet/invalid
HOW TO RUN TESTS:
==================
python setup.py develop
pip install -r test-requires.txt
nosetests tasklib/tests
For some functional tests installed puppet is required,
so if you dont have puppet - they will be skipped

3
tasklib/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
argparse
daemonize
pyyaml

58
tasklib/setup.py Normal file
View File

@ -0,0 +1,58 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import setuptools
def requirements():
dir_path = os.path.dirname(os.path.realpath(__file__))
requirements = []
with open('{0}/requirements.txt'.format(dir_path), 'r') as reqs:
requirements = reqs.readlines()
return requirements
major_version = '0.1'
minor_version = '0'
name = 'tasklib'
version = "%s.%s" % (major_version, minor_version)
setuptools.setup(
name=name,
version=version,
description='Tasklib package',
long_description="""Tasklib is intended to be mediator between different
configuration management solutions and orchestrator.
This is required to support plugable tasks/actions with good
amount of control, such as stop/pause/poll state.
""",
classifiers=[
"Development Status :: 4 - Beta",
"Programming Language :: Python",
],
author='Mirantis Inc.',
author_email='product@mirantis.com',
url='http://mirantis.com',
keywords='tasklib mirantis',
packages=setuptools.find_packages(),
zip_safe=False,
install_requires=requirements(),
include_package_data=True,
entry_points={
'console_scripts': [
'taskcmd = tasklib.cli:main',
]})

View File

@ -0,0 +1,13 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,13 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,35 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from tasklib import exceptions
log = logging.getLogger(__name__)
class Action(object):
def __init__(self, task, config):
self.task = task
self.config = config
log.debug('Init action with task %s', self.task.name)
def verify(self):
if 'type' not in self.task.metadata:
raise exceptions.NotValidMetadata()
def run(self):
raise NotImplementedError('Should be implemented by action driver.')

View File

@ -0,0 +1,44 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from tasklib.actions import action
from tasklib import exceptions
from tasklib import utils
log = logging.getLogger(__name__)
class ExecAction(action.Action):
def verify(self):
super(ExecAction, self).verify()
if 'cmd' not in self.task.metadata:
raise exceptions.NotValidMetadata()
@property
def command(self):
return self.task.metadata['cmd']
def run(self):
log.debug('Running task %s with command %s',
self.task.name, self.command)
exit_code, stdout, stderr = utils.execute(self.command)
log.debug(
'Task %s with command %s\n returned code %s\n out %s err%s',
self.task.name, self.command, exit_code, stdout, stderr)
if exit_code != 0:
raise exceptions.Failed()
return exit_code

View File

@ -0,0 +1,68 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import os
from tasklib.actions import action
from tasklib import exceptions
from tasklib import utils
log = logging.getLogger(__name__)
class PuppetAction(action.Action):
def run(self):
log.debug('Running puppet task %s with command %s',
self.task.name, self.command)
exit_code, stdout, stderr = utils.execute(self.command)
log.debug(
'Task %s with command %s\n returned code %s\n out %s err%s',
self.task.name, self.command, exit_code, stdout, stderr)
# 0 - no changes
# 2 - was some changes but successfull
# 4 - failures during transaction
# 6 - changes and failures
if exit_code not in [0, 2]:
raise exceptions.Failed()
return exit_code
@property
def manifest(self):
return (self.task.metadata.get('puppet_manifest') or
self.config['puppet_manifest'])
@property
def puppet_options(self):
if 'puppet_options' in self.task.metadata:
return self.task.metadata['puppet_options']
return self.config['puppet_options']
@property
def puppet_modules(self):
return (self.task.metadata.get('puppet_modules') or
self.config['puppet_modules'])
@property
def command(self):
cmd = ['puppet', 'apply', '--detailed-exitcodes']
if self.puppet_modules:
cmd.append('--modulepath={0}'.format(self.puppet_modules))
if self.puppet_options:
cmd.append(self.puppet_options)
if self.config['debug']:
cmd.append('--debug --verbose --evaltrace')
cmd.append(os.path.join(self.task.dir, self.manifest))
return ' '.join(cmd)

94
tasklib/tasklib/agent.py Normal file
View File

@ -0,0 +1,94 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import os
import daemonize
from tasklib import exceptions
from tasklib import task
from tasklib import utils
log = logging.getLogger(__name__)
class TaskAgent(object):
def __init__(self, task_name, config):
log.debug('Initializing task agent for task %s', task_name)
self.config = config
self.task = task.Task(task_name, self.config)
self.init_directories()
def init_directories(self):
utils.ensure_dir_created(self.config['pid_dir'])
utils.ensure_dir_created(self.config['report_dir'])
utils.ensure_dir_created(self.task.pid_dir)
utils.ensure_dir_created(self.task.report_dir)
def run(self):
try:
self.set_status(utils.STATUS.running.name)
result = self.task.run()
self.set_status(utils.STATUS.end.name)
return result
except exceptions.NotFound as exc:
log.warning('Cant find task %s with path %s',
self.task.name, self.task.file)
self.set_status(utils.STATUS.notfound.name)
except exceptions.Failed as exc:
log.error('Task %s failed with msg %s', self.task.name, exc.msg)
self.set_status(utils.STATUS.failed.name)
except Exception:
log.exception('Task %s erred', self.task.name)
self.set_status(utils.STATUS.error.name)
finally:
self.clean()
def __str__(self):
return 'tasklib agent - {0}'.format(self.task.name)
def report(self):
return 'placeholder'
def status(self):
if not os.path.exists(self.task.status_file):
return utils.STATUS.notfound.name
with open(self.task.status_file) as f:
return f.read()
def set_status(self, status):
with open(self.task.status_file, 'w') as f:
f.write(status)
def code(self):
status = self.status()
return getattr(utils.STATUS, status).code
@property
def pid(self):
return os.path.join(self.task.pid_dir, 'run.pid')
def daemon(self):
log.debug('Daemonizing task %s with pidfile %s',
self.task.name, self.pid)
daemon = daemonize.Daemonize(
app=str(self), pid=self.pid, action=self.run)
daemon.start()
def clean(self):
if os.path.exists(self.pid):
os.unlink(self.pid)

115
tasklib/tasklib/cli.py Normal file
View File

@ -0,0 +1,115 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Tasklib cmd interface
Exit Codes:
ended successfully - 0
running - 1
valid but failed - 2
unexpected error - 3
notfound such task - 4
"""
import argparse
import sys
import textwrap
import yaml
from tasklib import agent
from tasklib import config
from tasklib import logger
from tasklib import task
from tasklib import utils
class CmdApi(object):
def __init__(self):
self.parser = argparse.ArgumentParser(
description=textwrap.dedent(__doc__),
formatter_class=argparse.RawDescriptionHelpFormatter)
self.subparser = self.parser.add_subparsers(
title='actions',
description='Supported actions',
help='Provide of one valid actions')
self.config = config.Config()
self.register_options()
self.register_actions()
def register_options(self):
self.parser.add_argument(
'--config', '-c', dest='config', default=None,
help='Path to configuration file')
self.parser.add_argument(
'--debug', '-d', dest='debug', action='store_true', default=None)
def register_actions(self):
task_arg = [(('task',), {'type': str})]
self.register_parser('list')
self.register_parser('conf')
for name in ('run', 'daemon', 'report', 'status', 'show'):
self.register_parser(name, task_arg)
def register_parser(self, func_name, arguments=()):
parser = self.subparser.add_parser(func_name)
parser.set_defaults(func=getattr(self, func_name))
for args, kwargs in arguments:
parser.add_argument(*args, **kwargs)
def parse(self, args):
parsed = self.parser.parse_args(args)
if parsed.config:
self.config.update_from_file(parsed.config)
if parsed.debug is not None:
self.config['debug'] = parsed.debug
logger.setup_logging(self.config)
return parsed.func(parsed)
def list(self, args):
for task_dir in utils.find_all_tasks(self.config):
print(task.Task.task_from_dir(task_dir, self.config))
def show(self, args):
meta = task.Task(args.task, self.config).metadata
print(yaml.dump(meta, default_flow_style=False))
def run(self, args):
task_agent = agent.TaskAgent(args.task, self.config)
task_agent.run()
status = task_agent.status()
print(status)
return task_agent.code()
def daemon(self, args):
task_agent = agent.TaskAgent(args.task, self.config)
task_agent.daemon()
def report(self, args):
task_agent = agent.TaskAgent(args.task, self.config)
print(task_agent.report())
def status(self, args):
task_agent = agent.TaskAgent(args.task, self.config)
exit_code = task_agent.code()
print(task_agent.status())
return exit_code
def conf(self, args):
print(self.config)
def main():
api = CmdApi()
exit_code = api.parse(sys.argv[1:])
exit(exit_code)

57
tasklib/tasklib/config.py Normal file
View File

@ -0,0 +1,57 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import yaml
class Config(object):
def __init__(self, config_file=None):
self.curdir = os.getcwd()
self.config = self.default_config
if config_file:
self.update_from_file(config_file)
@property
def default_config(self):
return {
'library_dir': '/etc/puppet/tasks',
'puppet_modules': '/etc/puppet/modules',
'puppet_options': ('--logdest syslog '
'--logdest /var/log/puppet.log'
'--trace --no-report'),
'report_dir': '/var/tmp/task_report',
'pid_dir': '/var/tmp/task_run',
'puppet_manifest': 'site.pp',
'status_file': 'status',
'debug': True,
'task_file': 'task.yaml',
'log_file': '/var/log/tasklib.log'}
def update_from_file(self, config_file):
if os.path.exists(config_file):
with open(config_file) as f:
loaded = yaml.load(f.read())
self.config.update(loaded)
def __getitem__(self, key):
return self.config.get(key, None)
def __setitem__(self, key, value):
self.config[key] = value
def __repr__(self):
return yaml.dump(self.config, default_flow_style=False)

View File

@ -0,0 +1,33 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
class TasklibException(Exception):
msg = 'Tasklib generic error'
class NotFound(TasklibException):
msg = 'No task with provided name'
class NotValidMetadata(TasklibException):
msg = 'Missing critical items in metadata'
class Failed(TasklibException):
msg = 'Task failed'

37
tasklib/tasklib/logger.py Normal file
View File

@ -0,0 +1,37 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import sys
def setup_logging(config):
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'%(asctime)s %(levelname)s %(process)d (%(module)s) %(message)s',
"%Y-%m-%d %H:%M:%S")
if sys.stdout.isatty():
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.DEBUG)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
if config['log_file']:
file_handler = logging.FileHandler(config['log_file'])
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger

79
tasklib/tasklib/task.py Normal file
View File

@ -0,0 +1,79 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import os
import yaml
from tasklib.actions import exec_action
from tasklib.actions import puppet
from tasklib import exceptions
# use stevedore here
type_mapping = {'exec': exec_action.ExecAction,
'puppet': puppet.PuppetAction}
log = logging.getLogger(__name__)
class Task(object):
"""Unit of execution. Contains pre/post/run subtasks."""
def __init__(self, task_name, config):
self.config = config
self.name = task_name
self.dir = os.path.abspath(
os.path.join(config['library_dir'], self.name))
self.file = os.path.abspath(
os.path.join(self.dir, config['task_file']))
self.pid_dir = os.path.abspath(
os.path.join(self.config['pid_dir'], self.name))
self.report_dir = os.path.abspath(
os.path.join(self.config['report_dir'], self.name))
self.status_file = os.path.abspath(os.path.join(
self.report_dir, self.config['status_file']))
self._metadata = {}
log.debug('Init task %s with task file %s', self.name, self.file)
def verify(self):
if not os.path.exists(self.file):
raise exceptions.NotFound()
@property
def metadata(self):
if self._metadata:
return self._metadata
with open(self.file) as f:
self._metadata = yaml.load(f.read())
return self._metadata
@classmethod
def task_from_dir(cls, task_dir, config):
path = task_dir.replace(config['library_dir'], '').split('/')
task_name = '/'.join((p for p in path if p))
return cls(task_name, config)
def __repr__(self):
return "{0:10} | {1:15}".format(self.name, self.dir)
def run(self):
"""Will be used to run a task."""
self.verify()
action_class = type_mapping.get(self.metadata.get('type'))
if action_class is None:
raise exceptions.NotValidMetadata()
action = action_class(self, self.config)
action.verify()
return action.run()

View File

@ -0,0 +1,13 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,52 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import time
import unittest
from tasklib import utils
from tasklib.utils import STATUS
class BaseUnitTest(unittest.TestCase):
"""Tasklib base unittest."""
class BaseFunctionalTest(BaseUnitTest):
def setUp(self):
self.dir_path = os.path.dirname(os.path.realpath(__file__))
self.conf_path = os.path.join(self.dir_path, 'functional', 'conf.yaml')
self.base_command = ['taskcmd', '-c', self.conf_path]
def check_puppet_installed(self):
exit_code, out, err = utils.execute('which puppet')
if exit_code == 1:
self.skipTest('Puppet is not installed')
def execute(self, add_command):
command = self.base_command + add_command
cmd = ' '.join(command)
return utils.execute(cmd)
def wait_ready(self, cmd, timeout):
started = time.time()
while time.time() - started < timeout:
exit_code, out, err = self.execute(cmd)
if out.strip('\n') != STATUS.running.name:
return exit_code, out, err
self.fail('Command {0} failed to finish with timeout {1}'.format(
cmd, timeout))

View File

@ -0,0 +1,13 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,5 @@
library_dir: tasklib/tests/functional
pid_dir: tmp/task_run
report_dir: tmp/task_report
debug: true
log_file: tasklib.log

View File

@ -0,0 +1,2 @@
type: exec
description: "Should fail on validation"

View File

@ -0,0 +1,3 @@
type: exec
cmd: 'echo 42 | grep 100'
description: "grep will return 1, if nothing will be found"

View File

@ -0,0 +1,3 @@
type: exec
cmd: sleep 1.5
description: "Will be used for testing stop action"

View File

@ -0,0 +1,3 @@
type: exec
cmd: echo 1
description: "Most primitive task available"

View File

@ -0,0 +1,3 @@
exec { "which which":
path => ["/usr/bin", "/usr/sbin"]
}

View File

@ -0,0 +1 @@
type: puppet

View File

@ -0,0 +1,6 @@
file {"tasklibtest":
path => "/tmp/tasklibtest",
ensure => present,
mode => 0640,
content => "I'm a file created created by tasklib test",
}

View File

@ -0,0 +1,2 @@
type: puppet
puppet_manifest: file.pp

View File

@ -0,0 +1 @@
file {"invalid":

View File

@ -0,0 +1,2 @@
type: puppet
description: "site.pp is default manifest provided by config"

View File

@ -0,0 +1,3 @@
exec { "echo 15":
path => ["/bin", "sbin"]
}

View File

@ -0,0 +1,2 @@
type: puppet
description: 'Just sleep fot 1 seconds'

View File

@ -0,0 +1,34 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from tasklib.tests import base
from tasklib.utils import STATUS
class TestDaemon(base.BaseFunctionalTest):
def test_exec_long_task(self):
exit_code, out, err = self.execute(['daemon', 'exec/long'])
self.assertEqual(exit_code, 0)
exit_code, out, err = self.wait_ready(['status', 'exec/long'], 2)
self.assertEqual(exit_code, 0)
self.assertEqual(out.strip('\n'), STATUS.end.name)
def test_puppet_simple_daemon(self):
self.check_puppet_installed()
exit_code, out, err = self.execute(['daemon', 'puppet/sleep'])
self.assertEqual(exit_code, 0)
exit_code, out, err = self.wait_ready(['status', 'puppet/sleep'], 10)
self.assertEqual(exit_code, 0)
self.assertEqual(out.strip('\n'), STATUS.end.name)

View File

@ -0,0 +1,51 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from tasklib.tests import base
from tasklib.utils import STATUS
class TestFunctionalExecTasks(base.BaseFunctionalTest):
"""Each test will follow next pattern:
1. Run test with provided name - taskcmd -c conf.yaml run test/test
2. check status of task
"""
def test_simple_run(self):
exit_code, out, err = self.execute(['run', 'exec/simple'])
self.assertEqual(exit_code, 0)
exit_code, out, err = self.execute(['status', 'exec/simple'])
self.assertEqual(out.strip('\n'), STATUS.end.name)
self.assertEqual(exit_code, 0)
def test_failed_run(self):
exit_code, out, err = self.execute(['run', 'exec/fail'])
self.assertEqual(exit_code, 2)
exit_code, out, err = self.execute(['status', 'exec/fail'])
self.assertEqual(out.strip('\n'), STATUS.failed.name)
self.assertEqual(exit_code, 2)
def test_error(self):
exit_code, out, err = self.execute(['run', 'exec/error'])
self.assertEqual(exit_code, 3)
exit_code, out, err = self.execute(['status', 'exec/error'])
self.assertEqual(out.strip('\n'), STATUS.error.name)
self.assertEqual(exit_code, 3)
def test_notfound(self):
exit_code, out, err = self.execute(['run', 'exec/notfound'])
self.assertEqual(exit_code, 4)
exit_code, out, err = self.execute(['status', 'exec/notfound'])
self.assertEqual(out.strip('\n'), STATUS.notfound.name)
self.assertEqual(exit_code, 4)

View File

@ -0,0 +1,39 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
from tasklib.tests import base
class TestFunctionalExecTasks(base.BaseFunctionalTest):
"""Each test will follow next pattern:
1. Run test with provided name - taskcmd -c conf.yaml run test/test
2. check status of task
"""
def test_puppet_file(self):
test_file = '/tmp/tasklibtest'
if os.path.exists(test_file):
os.unlink(test_file)
exit_code, out, err = self.execute(['run', 'puppet/file'])
self.assertEqual(exit_code, 0)
def test_puppet_invalid(self):
exit_code, out, err = self.execute(['run', 'puppet/invalid'])
self.assertEqual(exit_code, 2)
def test_puppet_cmd(self):
exit_code, out, err = self.execute(['run', 'puppet/cmd'])
self.assertEqual(exit_code, 0)

View File

@ -0,0 +1,37 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
from tasklib import cli
from tasklib.tests import base
class TestCmdApi(base.BaseUnitTest):
def setUp(self):
self.api = cli.CmdApi()
self.api.config['log_file'] = None
@mock.patch('tasklib.cli.task.Task.task_from_dir')
@mock.patch('tasklib.cli.utils.find_all_tasks')
def test_list(self, mfind, mtask):
tasks = ['/etc/library/test/deploy', '/etc/library/test/rollback']
mfind.return_value = tasks
self.api.parse(['list'])
mfind.assert_called_once_with(self.api.config)
expected_calls = []
for t in tasks:
expected_calls.append(mock.call(t, self.api.config))
self.assertEqual(expected_calls, mtask.call_args_list)

View File

@ -0,0 +1,49 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
import yaml
from tasklib import config
from tasklib.tests import base
@mock.patch('tasklib.task.os.path.exists')
class TestConfig(base.BaseUnitTest):
def test_default_config_when_no_file_exists(self, mexists):
mexists.return_value = False
conf = config.Config(config_file='/etc/tasklib/test.yaml')
self.assertEqual(conf.default_config, conf.config)
def test_default_when_no_file_provided(self, mexists):
conf = config.Config()
self.assertEqual(conf.default_config, conf.config)
def test_non_default_config_from_valid_yaml(self, mexists):
mexists.return_value = True
provided = {'library_dir': '/var/run/tasklib',
'puppet_manifest': 'init.pp'}
mopen = mock.mock_open(read_data=yaml.dump(provided))
with mock.patch('tasklib.config.open', mopen, create=True):
conf = config.Config(config_file='/etc/tasklib/test.yaml')
self.assertNotEqual(
conf.config['library_dir'], conf.default_config['library_dir'])
self.assertEqual(
conf.config['library_dir'], provided['library_dir'])
self.assertNotEqual(
conf.config['puppet_manifest'],
conf.default_config['puppet_manifest'])
self.assertEqual(
conf.config['puppet_manifest'], provided['puppet_manifest'])

View File

@ -0,0 +1,41 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
import yaml
from tasklib import config
from tasklib import task
from tasklib.tests import base
@mock.patch('tasklib.task.os.path.exists')
@mock.patch('tasklib.utils.execute')
class TestExecTask(base.BaseUnitTest):
def setUp(self):
self.meta = {'cmd': 'echo 1',
'type': 'exec'}
self.only_required = {'type': 'puppet'}
self.config = config.Config()
def test_base_cmd_task(self, mexecute, mexists):
mexists.return_value = True
mexecute.return_value = (0, '', '')
mopen = mock.mock_open(read_data=yaml.dump(self.meta))
puppet_task = task.Task('test/cmd', self.config)
with mock.patch('tasklib.task.open', mopen, create=True):
puppet_task.run()
expected_cmd = 'echo 1'
mexecute.assert_called_once_with(expected_cmd)

View File

@ -0,0 +1,63 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
import yaml
from tasklib import config
from tasklib import task
from tasklib.tests import base
@mock.patch('tasklib.task.os.path.exists')
@mock.patch('tasklib.utils.execute')
class TestPuppetTask(base.BaseUnitTest):
def setUp(self):
self.meta = {'puppet_manifest': 'init.pp',
'puppet_options': '--debug',
'puppet_modules': '/etc/my_puppet_modules',
'type': 'puppet'}
self.only_required = {'type': 'puppet'}
self.config = config.Config()
def test_basic_puppet_action(self, mexecute, mexists):
mexists.return_value = True
mexecute.return_value = (0, '', '')
mopen = mock.mock_open(read_data=yaml.dump(self.meta))
puppet_task = task.Task('test/puppet', self.config)
with mock.patch('tasklib.task.open', mopen, create=True):
puppet_task.run()
expected_cmd = ['puppet', 'apply', '--detailed-exitcodes',
'--modulepath=/etc/my_puppet_modules',
'--debug']
expected = ' '.join(expected_cmd)
self.assertEqual(mexecute.call_count, 1)
received = mexecute.call_args[0][0]
self.assertTrue(expected in received)
def test_default_puppet_action(self, mexecute, mexists):
mexists.return_value = True
mexecute.return_value = (0, '', '')
mopen = mock.mock_open(read_data=yaml.dump(self.only_required))
puppet_task = task.Task('test/puppet/only_required', self.config)
with mock.patch('tasklib.task.open', mopen, create=True):
puppet_task.run()
expected_cmd = [
'puppet', 'apply', '--detailed-exitcodes',
'--modulepath={0}'.format(self.config['puppet_modules'])]
expected = ' '.join(expected_cmd)
self.assertEqual(mexecute.call_count, 1)
received = mexecute.call_args[0][0]
self.assertTrue(expected in received)

View File

@ -0,0 +1,57 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import mock
import yaml
from tasklib import config
from tasklib import exceptions
from tasklib import task
from tasklib.tests import base
@mock.patch('tasklib.task.os.path.exists')
class TestBaseTask(base.BaseUnitTest):
"""Basic task tests."""
def setUp(self):
self.conf = config.Config()
def test_create_task_from_path(self, mexists):
name = 'ceph/deploy'
task_dir = os.path.join(self.conf['library_dir'], name)
test_task = task.Task.task_from_dir(task_dir, self.conf)
self.assertEqual(test_task.name, name)
self.assertEqual(test_task.dir, task_dir)
def test_verify_raises_not_found(self, mexists):
mexists.return_value = False
test_task = task.Task('ceph/deploy', self.conf)
self.assertRaises(exceptions.NotFound, test_task.verify)
def test_verify_nothing_happens_if_file_exists(self, mexists):
mexists.return_value = True
test_task = task.Task('ceph/deploy', self.conf)
test_task.verify()
def test_read_metadata_from_valid_yaml(self, mexists):
mexists.return_value = True
meta = {'report_dir': '/tmp/report_dir',
'pid_dir': '/tmp/pid_dir'}
mopen = mock.mock_open(read_data=yaml.dump(meta))
test_task = task.Task('ceph/deploy', self.conf)
with mock.patch('tasklib.task.open', mopen, create=True):
self.assertEqual(meta, test_task.metadata)

51
tasklib/tasklib/utils.py Normal file
View File

@ -0,0 +1,51 @@
# Copyright 2014 Mirantis, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from collections import namedtuple
import fnmatch
import os
import subprocess
Status = namedtuple('Status', ['name', 'code'])
def key_value_enum(enums):
enums = dict([(k, Status(k, v)) for k, v in enums.iteritems()])
return type('Enum', (), enums)
STATUS = key_value_enum({'running': 1,
'end': 0,
'error': 3,
'notfound': 4,
'failed': 2})
def find_all_tasks(config):
for root, dirnames, filenames in os.walk(config['library_dir']):
for filename in fnmatch.filter(filenames, config['task_file']):
yield os.path.dirname(os.path.join(root, filename))
def ensure_dir_created(path):
if not os.path.exists(path):
os.makedirs(path)
def execute(cmd):
command = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
stdout, stderr = command.communicate()
return command.returncode, stdout, stderr

View File

@ -0,0 +1,4 @@
-r requirements.txt
mock
nose
tox

38
tasklib/tox.ini Normal file
View File

@ -0,0 +1,38 @@
[tox]
minversion = 1.6
skipsdist = True
envlist = py26,py27,pep8
[testenv]
usedevelop = True
install_command = pip install {packages}
setenv = VIRTUAL_ENV={envdir}
deps = -r{toxinidir}/test-requirements.txt
commands =
nosetests {posargs:tasklib}
[tox:jenkins]
downloadcache = ~/cache/pip
[testenv:pep8]
deps = hacking==0.7
usedevelop = False
commands =
flake8 {posargs:tasklib}
[testenv:venv]
commands = {posargs:}
[testenv:devenv]
envdir = devenv
usedevelop = True
[flake8]
ignore = H234,H302,H802
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,tools,__init__.py,docs
show-pep8 = True
show-source = True
count = True
[hacking]
import_exceptions = testtools.matchers