Merge pull request #50 from adobdin/master

merge 1.8.1
This commit is contained in:
Alexander Dobdin 2016-06-21 15:30:02 +04:00 committed by GitHub
commit bcc324c426
10 changed files with 267 additions and 65 deletions

View File

@ -25,16 +25,21 @@ Configuring actions
Actions can be configured in a separate yaml file (by default ``rq.yaml`` is used) and / or defind in the main config file or passed via command line options ``-P``, ``-C``, ``-S``, ``-G``.
The following actions are available for definition:
* **put** - a list of tuples / 2-element lists: [source, destination]. Passed to ``scp`` like so ``scp source <node-ip>:destination``. Wildcards supported for source.
* **cmds** - a list of dicts: {'command-name':'command-string'}. Example: {'command-1': 'uptime'}. Command string is a bash string. Commands are executed in a sorted order of their names.
* **scripts** - a list of script filenames located on a local system. If filename does not contain path separator, the script is expected ot be located inside ``rqdir/scripts``. Otherwise the provided path is used to read the script.
* **scripts** - a list of elements, each of which can be a string or a dict:
* string - represents a script filename located on a local system. If filename does not contain a path separator, the script is expected ot be located inside ``rqdir/scripts``. Otherwise the provided path is used to access the script. Example: ``'./my-test-script.sh'``
* dict - use this option if you need to pass variables to your script. Script parameters are not supported, but instead you can use env variables. A dict should only contain one key which is the script filename (read above), and the value is a Bash space-separated variable assignment string. Example: ``'./my-test-script.sh': 'var1=123 var2="HELLO WORLD"'``
* **LIMITATION**: if you use a script with the same name more than once for a given node, the collected output will only contain the last execution's result.
* **INFO**: Scripts are not copied to the destination system - script code is passed as stdin to `bash -s` executed via ssh or locally. Therefore passing parameters to scripts is not supported (unlike cmds where you can write any Bash string). You can use variables in your scripts instead. Scripts are executed in the following order: all scripts without variables, sorted by their full filename, then all scripts with variables, also sorted by full filename. Therefore if the order matters, it's better to put all scripts into the same folder and name them according to the order in which you want them executed on the same node, and mind that scripts with variables are executed after all scripts without variables. If you need to mix scripts with variables and without and maintain order, just use dict structure for all scripts, and set `null` as the value for those which do not need variables.
* **files** - a list of filenames to collect. passed to ``scp``. Supports wildcards.
* **filelists** - a list of filelist filenames located on a local system. Filelist is a text file containing files and directories to collect, passed to rsync. Does not support wildcards. If filename does not contain path separator, the filelist is expected to be located inside ``rqdir/filelists``. Otherwise the provided path is used to read the filelist.
* **log_files**
** **path** - base path to scan for logs
** **include** - regexp string to match log files against for inclusion (if not set = include all)
** **exclude** - regexp string to match log files against. Excludes matched files from collection.
** **start** - date or datetime string to collect only files modified on or after the specified time. Format - ``YYYY-MM-DD`` or ``YYYY-MM-DD HH:MM:SS``
* **path** - base path to scan for logs
* **include** - regexp string to match log files against for inclusion (if not set = include all)
* **exclude** - regexp string to match log files against. Excludes matched files from collection.
* **start** - date or datetime string to collect only files modified on or after the specified time. Format - ``YYYY-MM-DD`` or ``YYYY-MM-DD HH:MM:SS``
===============
Filtering nodes
@ -77,6 +82,16 @@ It is possible to define special **by_<parameter-name>** dicts in config to (re)
In this example for any controller node, cmds setting will be reset to the value above. For nodes without controller role, default (none) values will be used.
It is also possible to define a special **once_by_<parameter-name>** which works similarly, but will only result in attributes being assigned to single (first in the list) matching node. Example:
::
once_by_roles:
controller:
cmds: {'check-uptime': 'uptime'}
Such configuration will result in `uptime` being executed on only one node with controller role, not on every controller.
=============
rqfile format
=============
@ -108,13 +123,14 @@ Configuration application order
===============================
Configuration is assembled and applied in a specific order:
1. default configuration is initialized. See ``timmy/conf.py`` for details.
2. command line parameters, if defined, are used to modify the configuration.
3. **rqfile**, if defined (default - ``rq.yaml``), is converted and injected into the configuration. At this stage the configuration is in its final form.
4. for every node, configuration is applied, except ``once_by_`` directives:
4.1 first the top-level attributes are set
4.2 then ``by_<attribute-name>`` parameters except ``by_id`` are iterated to override or append(accumulate) the attributes
4.3 then ``by_id`` is iterated to override any matching attributes, redefining what was set before
1. first the top-level attributes are set
2. then ``by_<attribute-name>`` parameters except ``by_id`` are iterated to override or append(accumulate) the attributes
3. then ``by_id`` is iterated to override any matching attributes, redefining what was set before
5. finally ``once_by_`<attribute-name>`` parameters are applied - only for one matching node for any set of matching values. This is useful for example if you want a specific file or command from only a single node matching a specific role, like running ``nova list`` only on one controller.
Once you are done with the configuration, you might want to familiarize yourself with :doc:`Usage </usage>`.

View File

@ -12,28 +12,39 @@ Basic parameters:
* ``--only-logs`` only collect logs (skip files, filelists, commands and scripts)
* ``-l``, ``--logs`` also collect logs (logs are not collected by default due to their size)
* ``-C <command>`` enables ``shell mode``\*, Bash command (string) to execute on nodes. Using multiple ``-C`` statements will give the same result as using one with several commands separated by ``;`` (traditional Shell syntax), but for each ``-C`` statement a new SSH connection is established
* ``-S <script>`` enables ``shell mode``, name of the Bash script file (you need to put it into ``scripts`` folder inside a path specified by ``rqdir`` config parameter, defaults to ``rq``) to execute on nodes
* ``-P <file/path> <dest>`` enables ``shell mode``, upload local data to nodes (wildcards supported). You must specify 2 values for each ``-P`` switch.
* ``-G <file/path>`` enables ``shell mode``, download (collect) data from nodes
* ``-e``, ``--env`` filter by environment ID
* ``-R``, ``--role`` filter by role
* ``--config`` use custom configuration file to overwrite defaults. See ``config.yaml`` as an example
* ``-c``, ``--config`` use custom configuration file to overwrite defaults. See ``config.yaml`` as an example
* ``-j``, ``--nodes-json`` use json file instead of polling Fuel (to generate json file use ``fuel node --json``) - speeds up initialization
* ``-o``, ``--dest-file`` the name/path for output archive, default is ``general.tar.gz`` and put into ``/tmp/timmy/archives``.
* ``-v``, ``--verbose`` verbose(INFO) logging
* ``-d``, ``--debug`` debug(DEBUG) logging
Shell mode - rqfile (``rq.yaml`` by default) is skipped, Fuel node is skipped, outputs of commands (specified with ``-C`` options) and scripts (specified with ``-S``) are printed on screen.
**Shell Mode** - a mode of exectution which does the following changes:
* rqfile (``rq.yaml`` by default) is skipped
* Fuel node is skipped
* outputs of commands (specified with ``-C`` options) and scripts (specified with ``-S``) are printed on screen
* any actions (cmds, scripts, files, filelists, put, **except** logs) and Parameter Based configuration defined in config are ignored.
The following parameters ("actions") are available, using any of them enables **Shell Mode**:
* ``-C <command>`` - Bash command (string) to execute on nodes. Using multiple ``-C`` statements will give the same result as using one with several commands separated by ``;`` (traditional Shell syntax), but for each ``-C`` statement a new SSH connection is established
* ``-S <script>`` - name of the Bash script file to execute on nodes (if you do not have a path separator in the filename, you need to put the file into ``scripts`` folder inside a path specified by ``rqdir`` config parameter, defaults to ``rq``. If path separator is present, a given filename will be used directly as provided)
* ``-P <file/path> <dest>`` - upload local data to nodes (wildcards supported). You must specify 2 values for each ``-P`` switch.
* ``-G <file/path>`` - download (collect) data from nodes
========
Examples
========
* ``timmy`` - run according to the default configuration and default actions. Default actions are defined in ``rq.yaml`` (``/usr/share/timmy/rq.yaml``). Logs are not collected.
* ``timmy -l`` - run default actions and also collect logs (default log setup applied - defaults are hardcoded in ``timmy/conf.py``). Such execution is similar to Fuel's "diagnostic snapshot" action, but will finish faster and collect less logs.
* ``timmy --only-logs`` - only collect logs, no actions performed (default log setup, as above)
* ``timmy -C 'uptime; free -m'`` - check uptime and memory on all nodes
* ``timmy -G /etc/nova/nova.conf`` - get nova.conf from all nodes
* ``timmy -R controller -P package.deb '' -C 'dpkg -i package.deb' -C 'rm package.deb' -C 'dpkg -l | grep [p]ackage'`` - push a package to all nodes, install it, remove the file and check that it is installed
* ``timmy -с myconf.yaml`` - use a custom config file and run according to it
* ``timmy -с myconf.yaml`` - use a custom config file and run according to it. Custom config can specify any actions, log setup, and other settings. See configuration doc for more details.
===============================
Using custom configuration file

View File

@ -1,4 +1,20 @@
#!/usr/bin/env python
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# Copyright 2015 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 setuptools import setup
import os

View File

@ -1,4 +1,19 @@
#!/usr/bin/env python
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# Copyright 2015 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 timmy.cli import main

View File

@ -54,7 +54,7 @@ def parse_args():
help='Redirect Timmy log to a file.')
parser.add_argument('-e', '--env', type=int,
help='Env ID. Run only on specific environment.')
parser.add_argument('-R', '--role', action='append',
parser.add_argument('-r', '--role', action='append',
help=('Can be specified multiple times.'
' Run only on the specified role.'))
parser.add_argument('-G', '--get', action='append',
@ -80,6 +80,8 @@ def parse_args():
' Each argument must contain two strings -'
' source file/path/mask and dest. file/path.'
' For help on shell mode, read timmy/conf.py.'))
parser.add_argument('--rqfile', help='Path to an rqfile in yaml format,'
' overrides default.')
parser.add_argument('-l', '--logs',
help=('Collect logs from nodes. Logs are not collected'
' by default due to their size.'),
@ -87,6 +89,9 @@ def parse_args():
parser.add_argument('--fuel-ip', help='fuel ip address')
parser.add_argument('--fuel-user', help='fuel username')
parser.add_argument('--fuel-pass', help='fuel password')
parser.add_argument('--fuel-proxy',
help='use os system proxy variables for fuelclient',
action='store_true')
parser.add_argument('--only-logs',
action='store_true',
help=('Only collect logs, do not run commands or'
@ -135,6 +140,9 @@ def parse_args():
'selected if more -v are provided it will '
'step to INFO and DEBUG unless the option '
'-q(--quiet) is specified'))
parser.add_argument('--fuel-cli', action='store_true',
help=('Use fuel command line client instead of '
'fuelclient library'))
return parser
@ -145,7 +153,7 @@ def main(argv=None):
parser = parse_args()
args = parser.parse_args(argv[1:])
loglevels = [logging.WARNING, logging.INFO, logging.DEBUG]
if args.quiet:
if args.quiet and not args.log_file:
args.verbose = 0
loglevel = loglevels[min(len(loglevels)-1, args.verbose)]
FORMAT = ('%(asctime)s %(levelname)s: %(module)s: '
@ -161,11 +169,15 @@ def main(argv=None):
conf['fuel_user'] = args.fuel_user
if args.fuel_pass:
conf['fuel_pass'] = args.fuel_pass
if args.fuel_proxy:
conf['fuel_skip_proxy'] = False
if args.put or args.command or args.script or args.get:
conf['shell_mode'] = True
conf['do_print_results'] = True
if args.no_clean:
conf['clean'] = False
if args.rqfile:
conf['rqfile'] = args.rqfile
if conf['shell_mode']:
filter = conf['hard_filter']
# config cleanup for shell mode
@ -201,6 +213,8 @@ def main(argv=None):
if args.dest_file:
conf['archive_dir'] = os.path.split(args.dest_file)[0]
conf['archive_name'] = os.path.split(args.dest_file)[1]
if args.fuel_cli:
conf['fuelclient'] = False
logger.info('Using rqdir: %s, rqfile: %s' %
(conf['rqdir'], conf['rqfile']))
nm = pretty_run(args.quiet, 'Initializing node data',

View File

@ -1,3 +1,20 @@
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# Copyright 2015 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 tools import load_yaml_file
from tempfile import gettempdir
import os
@ -15,6 +32,9 @@ def load_conf(filename):
conf['fuel_ip'] = '127.0.0.1'
conf['fuel_user'] = 'admin'
conf['fuel_pass'] = 'admin'
conf['fuel_tenant'] = 'admin'
conf['fuelclient'] = True # use fuelclient library by default
conf['fuel_skip_proxy'] = True
conf['timeout'] = 15
conf['prefix'] = 'nice -n 19 ionice -c 3'
rqdir = 'rq'

View File

@ -1,5 +1,22 @@
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# Copyright 2015 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.
project_name = 'timmy'
version = '1.5.1'
version = '1.8.1'
if __name__ == '__main__':
exit(0)

View File

@ -1,4 +1,26 @@
#! /usr/bin/env python
#! /usr/bin/env python2
# -*- coding: utf-8 -*-
# The MIT License (MIT)
# Copyright (c) 2009 Max Polk
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import os
import errno

View File

@ -30,6 +30,17 @@ import tools
from tools import w_list, run_with_lock
from copy import deepcopy
try:
from fuelclient.client import Client as FuelClient
except:
FuelClient = None
try:
from fuelclient.client import logger
logger.handlers = []
except:
pass
class Node(object):
ckey = 'cmds'
@ -166,11 +177,12 @@ class Node(object):
if code != 0:
self.logger.warning('node: %s: could not determine'
' MOS release' % self.id)
release = 'n/a'
else:
self.release = release.strip('\n "\'')
release = release.strip('\n "\'')
self.logger.info('node: %s, MOS release: %s' %
(self.id, self.release))
return self.release
(self.id, release))
return release
def exec_cmd(self, fake=False, ok_codes=None):
sn = 'node-%s' % self.id
@ -205,9 +217,14 @@ class Node(object):
dfile)
if self.scripts:
tools.mdir(ddir)
self.scripts = sorted(self.scripts)
scripts = sorted(self.scripts)
mapscr = {}
for scr in self.scripts:
for scr in scripts:
if type(scr) is dict:
env_vars = scr.values()[0]
scr = scr.keys()[0]
else:
env_vars = self.env_vars
if os.path.sep in scr:
f = scr
else:
@ -223,7 +240,7 @@ class Node(object):
outs, errs, code = tools.ssh_node(ip=self.ip,
filename=f,
ssh_opts=self.ssh_opts,
env_vars=self.env_vars,
env_vars=env_vars,
timeout=self.timeout,
prefix=self.prefix)
self.check_code(code, 'exec_cmd', 'script %s' % f, errs,
@ -295,6 +312,7 @@ class Node(object):
file=f[0],
dest=f[1],
recursive=True)
self.check_code(code, 'put_files', 'tools.put_file_scp', errs)
def logs_populate(self, timeout=5):
@ -388,17 +406,42 @@ class NodeManager(object):
self.import_rq()
self.nodes = {}
self.fuel_init()
# save os environment variables
environ = os.environ
if FuelClient and conf['fuelclient']:
try:
if self.conf['fuel_skip_proxy']:
os.environ['HTTPS_PROXY'] = ''
os.environ['HTTP_PROXY'] = ''
os.environ['https_proxy'] = ''
os.environ['http_proxy'] = ''
self.logger.info('Setup fuelclient instance')
self.fuelclient = FuelClient()
self.fuelclient.username = self.conf['fuel_user']
self.fuelclient.password = self.conf['fuel_pass']
self.fuelclient.tenant_name = self.conf['fuel_tenant']
# self.fuelclient.debug_mode(True)
except Exception as e:
self.logger.info('Failed to setup fuelclient instance:%s' % e,
exc_info=True)
self.fuelclient = None
else:
self.logger.info('Skipping setup fuelclient instance')
self.fuelclient = None
if nodes_json:
self.nodes_json = tools.load_json_file(nodes_json)
else:
self.nodes_json = json.loads(self.get_nodes_json())
if (not self.get_nodes_fuelclient() and
not self.get_nodes_cli()):
sys.exit(4)
self.nodes_init()
# apply soft-filter on all nodes
for node in self.nodes.values():
if not self.filter(node, self.conf['soft_filter']):
node.filtered_out = True
if not conf['shell_mode']:
self.nodes_get_release()
if not self.get_release_fuel_client():
self.get_release_cli()
self.nodes_reapply_conf()
self.conf_assign_once()
if extended:
@ -407,6 +450,8 @@ class NodeManager(object):
do additional apply_conf(clean=False) with this yaml.
Move some stuff from rq.yaml to extended.yaml'''
pass
# restore os environment variables
os.environ = environ
def __str__(self):
def ml_column(matrix, i):
@ -505,7 +550,51 @@ class NodeManager(object):
fuelnode.filtered_out = True
self.nodes[self.conf['fuel_ip']] = fuelnode
def get_nodes_json(self):
def get_nodes_fuelclient(self):
if not self.fuelclient:
return False
try:
self.nodes_json = self.fuelclient.get_request('nodes')
self.logger.debug(self.nodes_json)
return True
except Exception as e:
self.logger.warning(("NodeManager: can't "
"get node list from fuel client:\n%s" % (e)),
exc_info=True)
return False
def get_release_fuel_client(self):
if not self.fuelclient:
return False
try:
self.logger.info('getting release from fuel')
v = self.fuelclient.get_request('version')
fuel_version = v['release']
self.logger.debug('version response:%s' % v)
clusters = self.fuelclient.get_request('clusters')
self.logger.debug('clusters response:%s' % clusters)
except:
self.logger.warning(("Can't get fuel version or "
"clusters information"))
return False
self.nodes[self.conf['fuel_ip']].release = fuel_version
cldict = {}
for cluster in clusters:
cldict[cluster['id']] = cluster
if cldict:
for node in self.nodes.values():
if node.cluster:
node.release = cldict[node.cluster]['fuel_version']
else:
# set to n/a or may be fuel_version
if node.id != 0:
node.release = 'n/a'
self.logger.info('node: %s - release: %s' % (node.id,
node.release))
return True
def get_nodes_cli(self):
self.logger.info('use CLI for getting node information')
fuelnode = self.nodes[self.conf['fuel_ip']]
fuel_node_cmd = ('fuel node list --json --user %s --password %s' %
(self.conf['fuel_user'],
@ -516,10 +605,22 @@ class NodeManager(object):
timeout=fuelnode.timeout,
prefix=fuelnode.prefix)
if code != 0:
self.logger.critical(('NodeManager: cannot get '
'fuel node list: %s') % err)
sys.exit(4)
return nodes_json
self.logger.warning(('NodeManager: cannot get '
'fuel node list from CLI: %s') % err)
self.nodes_json = None
return False
self.nodes_json = json.loads(nodes_json)
return True
def get_release_cli(self):
run_items = []
for key, node in self.nodes.items():
if not node.filtered_out:
run_items.append(tools.RunItem(target=node.get_release,
key=key))
result = tools.run_batch(run_items, 100, dict_result=True)
for key in result:
self.nodes[key].release = result[key]
def nodes_init(self):
for node_data in self.nodes_json:
@ -544,16 +645,6 @@ class NodeManager(object):
if self.filter(node, self.conf['hard_filter']):
self.nodes[node.ip] = node
def nodes_get_release(self):
run_items = []
for key, node in self.nodes.items():
if not node.filtered_out:
run_items.append(tools.RunItem(target=node.get_release,
key=key))
result = tools.run_batch(run_items, 100, dict_result=True)
for key in result:
self.nodes[key].release = result[key]
def conf_assign_once(self):
once = Node.conf_once_prefix
p = Node.conf_match_prefix

View File

@ -31,7 +31,6 @@ from flock import FLock
from tempfile import gettempdir
from pipes import quote
logger = logging.getLogger(__name__)
slowpipe = '''
import sys
@ -148,26 +147,6 @@ def run_batch(item_list, maxthreads, dict_result=False):
raise KeyboardInterrupt()
def get_dir_structure(rootdir):
"""
Creates a nested dictionary that represents the folder structure of rootdir
"""
dir = {}
try:
rootdir = rootdir.rstrip(os.sep)
start = rootdir.rfind(os.sep) + 1
for path, dirs, files in os.walk(rootdir):
folders = path[start:].split(os.sep)
subdir = dict.fromkeys(files)
parent = reduce(dict.get, folders[:-1], dir)
parent[folders[-1]] = subdir
except:
logger.critical('failed to create list of the directory: %s' %
rootdir)
sys.exit(1)
return dir
def load_json_file(filename):
"""
Loads json data from file
@ -343,5 +322,6 @@ def free_space(destdir, timeout):
def w_list(value):
return value if type(value) == list else [value]
if __name__ == '__main__':
exit(0)