Skip to content

Commit

Permalink
Merge branch 'master' into debian
Browse files Browse the repository at this point in the history
  • Loading branch information
volans- committed Sep 21, 2017
2 parents 09e3296 + fcd69f9 commit 22b58c8
Show file tree
Hide file tree
Showing 18 changed files with 434 additions and 51 deletions.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# CUMIN CHANGELOG

## v1.1.0 (2017-09-21)

### New features:
* Backends: add OpenStack backend ([T175711](https://phabricator.wikimedia.org/T175711))

### Bug Fixes:
* CLI: fix --version option
* Installation: fix data_files installation directory ([T174008](https://phabricator.wikimedia.org/T174008))
* Transports: better handling of empty list ([T174911](https://phabricator.wikimedia.org/T174911)):
* BaseWorker: accept an empty list in the command setter. It's its default value, there is no point in forbidding a
client to set it to the same value.
* ClusterShellWorker: return immediately if there are no target hosts.
* Clustershell: make call to tqdm.write() explicit where to send the output, not relying on its default.


## v1.0.0 (2017-08-23)

### CLI breaking changes:
Expand Down
64 changes: 49 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ Given that the `pyparsing` library used to define the grammar uses a BNF-like st
specified above see directly the code in `cumin/backends/puppetdb.py`.


##### OpenStack

This backend uses the OpenStack APIs to perform the query. The specific query language has this features:

- Each query can specify multiple parameters to filter the hosts selection in the form `key:value`.
- The special `project` key allow to filter by the OpenStack project name: `project:project_name`. If not specified
all the visible and enabled projects will be queried.
- Any other `key:value` pair will be passed as is to the
[OpenStack list-servers API](https://developer.openstack.org/api-ref/compute/#list-servers).
Multiple filters can be added separated by space. The value can be enclosed in single or double quotes:
`name:"host1.*\.domain" image:UUID`
- By default the filters `status:ACTIVE` and `vm_state:ACTIVE` are also added, but will be overridden if specified in
the query.
- To mix multiple selections the general grammar must be used with multiple subqueries:
`O{project:project1} or O{project:project2}`
- The special query `*` is a shortcut to select all hosts in all OpenStack projects.
- See the example configuration in `doc/examples/config.yaml` for all the OpenStack-related parameters that can be set.

```
Backus-Naur form (BNF) of the grammar:
<grammar> ::= "*" | <items>
<items> ::= <item> | <item> <whitespace> <items>
<item> ::= <key>:<value>
```

Given that the `pyparsing` library used to define the grammar uses a BNF-like style, for the details of the tokens not
specified above see directly the code in `cumin/backends/openstack.py`.


##### Direct

The `direct` backend allow to use Cumin without any external dependency for the hosts selection.
Expand Down Expand Up @@ -183,17 +212,17 @@ Is it also possible to build a Debian package using the `debian` branch, for exa

The default configuration file for `cumin` CLI is expected to be found at `/etc/cumin/config.yaml`; the path can be
changed via a command-line switch, `--config`. A commented example configuration is available in
`doc/examples//config.yaml`.
`doc/examples/config.yaml`.

Cumin will also automatically load any aliases defined in a `aliases.yaml` file, if present in the same directory of
the main configuration file. An aliases example file is available in `doc/examples/aliases.yaml`

## CLI

### Usage

cumin [OPTIONS] HOSTS COMMAND [COMMAND ...]

```
cumin [OPTIONS] HOSTS COMMAND [COMMAND ...]
```
### OPTIONS

For the full list of available optional arguments see `cumin --help`.
Expand Down Expand Up @@ -224,18 +253,23 @@ Multiple commands will be executed sequentially.
## Running tests

The `tox` utility, a wrapper around virtualenv, is used to run the test. To list the available environements:

tox -l

```
tox -l
```
To run one:
```
tox -e flake8
```
You can pass extra arguments to the underlying command:
```
# Run only tests in a specific file:
tox -e unit -- -k test_puppetdb.py
tox -e flake8

You can pass extra arguments to the underlying command, for example to only run the tests in a specific file:

tox -e unit -- -k test_puppetdb.py

# Run only one specific test:
tox -e unit -- -k test_invalid_grammars
```
Also integration tests are available, but not run by default by tox. They depends on a running Docker instance.
To run them:

tox -e integration
```
tox -e integration
```
20 changes: 2 additions & 18 deletions cumin/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,6 @@ def _parse_token(self, token):
token -- a single token returned by the grammar parsing
"""

@abstractmethod
def _open_subgroup(self):
"""Is called when a subgroup is opened in the parsing of the query.
Given that each backend has it's own grammar and parsing logic, keeping this in the abstract class to force
each backend to support subgrouping in the grammar for usability and coherence between backends.
"""

@abstractmethod
def _close_subgroup(self):
"""Is called when a subgroup is closed in the parsing of the query.
Given that each backend has it's own grammar and parsing logic, keeping this in the abstract class to force
each backend to support subgrouping in the grammar for usability and coherence between backends.
"""

def _build(self, query_string):
"""Parse the query string according to the grammar and build the query for later execution.
Expand Down Expand Up @@ -121,14 +105,14 @@ def _execute(self):
return hosts

def _open_subgroup(self):
"""Required by BaseQuery."""
"""Handle subgroup opening."""
element = self._get_stack_element()
element['parent'] = self.stack_pointer
self.stack_pointer['children'].append(element)
self.stack_pointer = element

def _close_subgroup(self):
"""Required by BaseQuery."""
"""Handle subgroup closing."""
self.stack_pointer = self.stack_pointer['parent']

@abstractmethod
Expand Down
165 changes: 165 additions & 0 deletions cumin/backends/openstack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""OpenStack backend."""
import pyparsing as pp

from ClusterShell.NodeSet import NodeSet
from keystoneauth1 import session as keystone_session
from keystoneauth1.identity import v3 as keystone_identity
from keystoneclient.v3 import client as keystone_client
from novaclient import client as nova_client

from cumin.backends import BaseQuery, InvalidQueryError


def grammar():
"""Define the query grammar.
Some query examples:
- All hosts in all OpenStack projects: `*`
- All hosts in a specific OpenStack project: `project:project_name`
- Filter hosts using any parameter allowed by the OpenStack list-servers API: `name:host1 image:UUID`
See https://developer.openstack.org/api-ref/compute/#list-servers for more details.
Multiple filters can be added separated by space. The value can be enclosed in single or double quotes.
If the `project` key is not specified the hosts will be selected from all projects.
- To mix multiple selections the general grammar must be used with multiple subqueries:
`O{project:project1} or O{project:project2}`
Backus-Naur form (BNF) of the grammar:
<grammar> ::= "*" | <items>
<items> ::= <item> | <item> <whitespace> <items>
<item> ::= <key>:<value>
Given that the pyparsing library defines the grammar in a BNF-like style, for the details of the tokens not
specified above check directly the code.
"""
quoted_string = pp.quotedString.copy().addParseAction(pp.removeQuotes) # Both single and double quotes are allowed

# Key-value tokens: key:value
# All printables characters except the parentheses that are part of this or the global grammar
key = pp.Word(pp.alphanums + '-_.')('key')
all_but_par = ''.join([c for c in pp.printables if c not in ('(', ')', '{', '}')])
value = (quoted_string | pp.Word(all_but_par))('value')
item = pp.Combine(key + ':' + value)

# Final grammar, see the docstring for its BNF based on the tokens defined above
# Groups are used to split the parsed results for an easy access
return pp.Group(pp.Literal('*')('all')) | pp.OneOrMore(pp.Group(item))


def _get_keystone_session(config, project=None):
"""Return a new keystone session based on configuration.
Arguments:
config -- a dictionary with the session configuration: auth_url, username, password
project -- a project to scope the session to. [optional, default: None]
"""
auth = keystone_identity.Password(
auth_url='{auth_url}/v3'.format(auth_url=config.get('auth_url', 'http://localhost:5000')),
username=config.get('username', 'username'),
password=config.get('password', 'password'),
project_name=project,
user_domain_id='default',
project_domain_id='default')
return keystone_session.Session(auth=auth)


def _get_nova_client(config, project):
"""Return a new nova client tailored to the given project.
Arguments:
config -- a dictionary with the session configuration: auth_url, username, password, nova_api_version, timeout
project -- a project to scope the session to. [optional, default: None]
"""
return nova_client.Client(
config.get('nova_api_version', '2'),
session=_get_keystone_session(config, project),
endpoint_type='public',
timeout=config.get('timeout', 10))


class OpenStackQuery(BaseQuery):
"""OpenStackQuery query builder.
Query VMs deployed in an OpenStack infrastructure using the API.
"""

grammar = grammar()

def __init__(self, config, logger=None):
"""Query constructor for the OpenStack backend.
Arguments: according to BaseQuery interface
"""
super(OpenStackQuery, self).__init__(config, logger=logger)
self.openstack_config = self.config.get('openstack', {})
self.search_project = None
self.search_params = OpenStackQuery._get_default_search_params()

@staticmethod
def _get_default_search_params():
"""Return the default search parameters dictionary."""
return {'status': 'ACTIVE', 'vm_state': 'ACTIVE'}

def _build(self, query_string):
"""Override parent class _build method to reset search parameters."""
self.search_params = OpenStackQuery._get_default_search_params()
super(OpenStackQuery, self)._build(query_string)

def _execute(self):
"""Required by BaseQuery."""
if self.search_project is None:
hosts = NodeSet()
for project in self._get_projects():
hosts |= self._get_project_hosts(project)
else:
hosts = self._get_project_hosts(self.search_project)

return hosts

def _parse_token(self, token):
"""Required by BaseQuery."""
if not isinstance(token, pp.ParseResults): # pragma: no cover - this should never happen
raise InvalidQueryError('Expecting ParseResults object, got {type}: {token}'.format(
type=type(token), token=token))

token_dict = token.asDict()
self.logger.trace('Token is: {token_dict} | {token}'.format(token_dict=token_dict, token=token))

if 'key' in token_dict and 'value' in token_dict:
if token_dict['key'] == 'project':
self.search_project = token_dict['value']
else:
self.search_params[token_dict['key']] = token_dict['value']
elif 'all' in token_dict:
pass # nothing to do, search_project and search_params have the right defaults
else: # pragma: no cover - this should never happen
raise InvalidQueryError('Got unexpected token: {token}'.format(token=token))

def _get_projects(self):
"""Yield the project names for all projects (except admin) from keystone API."""
client = keystone_client.Client(
session=_get_keystone_session(self.openstack_config), timeout=self.openstack_config.get('timeout', 10))
return (project.name for project in client.projects.list(enabled=True) if project.name != 'admin')

def _get_project_hosts(self, project):
"""Return a NodeSet with the list of matching hosts based for the project based on the search parameters.
Arguments:
project -- the project name where to get the list of hosts
"""
client = _get_nova_client(self.openstack_config, project)

domain = ''
domain_suffix = self.openstack_config.get('domain_suffix', None)
if domain_suffix is not None:
if domain_suffix[0] != '.':
domain = '.{suffix}'.format(suffix=domain_suffix)
else:
domain = domain_suffix

return NodeSet.fromlist('{host}.{project}{domain}'.format(host=server.name, project=project, domain=domain)
for server in client.servers.list(search_opts=self.search_params))


# Required by the backend auto-loader in cumin.grammar.get_registered_backends()
GRAMMAR_PREFIX = 'O'
query_class = OpenStackQuery # pylint: disable=invalid-name
6 changes: 3 additions & 3 deletions cumin/backends/puppetdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def grammar():
neg = pp.CaselessKeyword('not')('neg')

operator = pp.oneOf(OPERATORS, caseless=True)('operator') # Comparison operators
quoted_string = pp.quotedString.addParseAction(pp.removeQuotes) # Both single and double quotes are allowed
quoted_string = pp.quotedString.copy().addParseAction(pp.removeQuotes) # Both single and double quotes are allowed

# Parentheses
lpar = pp.Literal('(')('open_subgroup')
Expand Down Expand Up @@ -138,14 +138,14 @@ def category(self, value):
self._category = value

def _open_subgroup(self):
"""Required by BaseQuery."""
"""Handle subgroup opening."""
token = PuppetDBQuery._get_grouped_tokens()
token['parent'] = self.current_group
self.current_group['tokens'].append(token)
self.current_group = token

def _close_subgroup(self):
"""Required by BaseQuery."""
"""Handle subgroup closing."""
self.current_group = self.current_group['parent']

@staticmethod
Expand Down
6 changes: 1 addition & 5 deletions cumin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def parse_args(argv=None):
'[optional]'))
parser.add_argument('--dry-run', action='store_true',
help='Do not execute any command, just return the list of matching hosts and exit.')
parser.add_argument('--version', action='store_true', help='Print current version and exit.')
parser.add_argument('--version', action='version', version='%(prog)s {version}'.format(version=cumin.__version__))
parser.add_argument('-d', '--debug', action='store_true', help='Set log level to DEBUG.')
parser.add_argument('--trace', action='store_true',
help='Set log level to TRACE, a custom logging level intended for development debugging.')
Expand Down Expand Up @@ -353,10 +353,6 @@ def main(argv=None):
# Setup
try:
args = parse_args(argv)
if args.version:
tqdm.write('cumin {version}'.format(version=cumin.__version__))
return 0

user = get_running_user()
config = cumin.Config(args.config)
setup_logging(config['log_file'], debug=args.debug, trace=args.trace)
Expand Down
6 changes: 6 additions & 0 deletions cumin/tests/fixtures/backends/grammars/openstack_invalid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Invalid grammars
* key:value
key:value key
key:value :value
"key":value
key%:value
10 changes: 10 additions & 0 deletions cumin/tests/fixtures/backends/grammars/openstack_valid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Valid grammars
*
name:host1
name:host10.*
name:"^host10[1-9]\.domain$"
name:'^host10[1-9]\.domain$'
project:project_name
project:"Project Name"
project:project_name name:host1
project:"Project Name" name:host1
Loading

0 comments on commit 22b58c8

Please sign in to comment.