Volans has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/405719 )

Change subject: Backends: add known hosts files backend
......................................................................

Backends: add known hosts files backend

Change-Id: Ic0f6b89c4c08e65dfebb6a61bab7b7831188b2ca
---
M README.rst
M TODO.rst
A cumin/backends/knownhosts.py
A cumin/tests/fixtures/backends/grammars/knownhosts_invalid.txt
A cumin/tests/fixtures/backends/grammars/knownhosts_valid.txt
A cumin/tests/fixtures/backends/knownhosts.txt
A cumin/tests/fixtures/backends/knownhosts_man.txt
A cumin/tests/unit/backends/test_knownhosts.py
M doc/examples/config.yaml
9 files changed, 530 insertions(+), 2 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/operations/software/cumin 
refs/changes/19/405719/1

diff --git a/README.rst b/README.rst
index 8790cdb..9026f74 100644
--- a/README.rst
+++ b/README.rst
@@ -10,7 +10,6 @@
 selected, and can provide multiple execution strategies. The executed commands 
outputs are automatically grouped for an
 easy-to-read result.
 
-
 It can be used both via its command line interface (CLI) `cumin` and as a 
Python 3 only library.
 Cumin was Python 2 only before the 3.0.0 release, due to ClusterShell not yet 
being Python 3 compatible.
 
diff --git a/TODO.rst b/TODO.rst
index d8d5527..0a431dd 100644
--- a/TODO.rst
+++ b/TODO.rst
@@ -51,7 +51,6 @@
   parameter. Needs a new local transport with ExecWorker to shell out in 
parallel.
 * backends: generalize backends to allow to return other data too, not only 
the host certnames.
 * backends: add a new backend to support conftool.
-* backends: add a new backed to query the known hosts file format.
 * puppetdb backend: add support for API v4.
 * CLI: when ``-i/--interactive`` is used and no command or query is specified, 
drop into a REPL session allowing to
   easily setup them.
diff --git a/cumin/backends/knownhosts.py b/cumin/backends/knownhosts.py
new file mode 100644
index 0000000..5ff41a3
--- /dev/null
+++ b/cumin/backends/knownhosts.py
@@ -0,0 +1,254 @@
+"""Known hosts backend."""
+import ipaddress
+
+import pyparsing as pp
+
+from ClusterShell.NodeSet import NodeSet
+from ClusterShell.NodeUtils import GroupResolver, GroupSource
+
+from cumin.backends import BaseQueryAggregator, InvalidQueryError
+
+
+def grammar():
+    """Define the query grammar.
+
+    Some query examples:
+
+    * Simple selection: ``host1.domain``
+    * ClusterShell syntax for hosts expansion: 
``host10[10-42].domain,host2010.other-domain``
+    * ClusterShell syntax for hosts globbing: ``host10[10-42]*``
+    * A complex selection: ``host100[1-5]* or (host10[30-40].domain and 
(host10[10-42].domain and not host33.domain))``
+
+    Backus-Naur form (BNF) of the grammar::
+
+        <grammar> ::= <item> | <item> <boolean> <grammar>
+           <item> ::= <hosts> | "(" <grammar> ")"
+        <boolean> ::= "and not" | "and" | "xor" | "or"
+
+    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 source code.
+
+    Returns:
+        pyparsing.ParserElement: the grammar parser.
+
+    """
+    # Boolean operators
+    boolean = (pp.CaselessKeyword('and not').leaveWhitespace() | 
pp.CaselessKeyword('and') |
+               pp.CaselessKeyword('xor') | pp.CaselessKeyword('or'))('bool')
+
+    # Parentheses
+    lpar = pp.Literal('(')('open_subgroup')
+    rpar = pp.Literal(')')('close_subgroup')
+
+    # Hosts selection: clustershell (,!&^[]) syntax is allowed: 
host10[10-42].domain
+    hosts = (~(boolean) + pp.Word(pp.alphanums + '-_.,!&^[]*?'))('hosts')
+
+    # 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
+    full_grammar = pp.Forward()
+    item = hosts | lpar + full_grammar + rpar
+    full_grammar << pp.Group(item) + pp.ZeroOrMore(pp.Group(boolean + item))  
# pylint: disable=expression-not-assigned
+
+    return full_grammar
+
+
+class KnownHostsLineError(InvalidQueryError):
+    """Custom exception class for invalid lines in SSH known hosts files."""
+
+
+class KnownHostsSkippedLineError(InvalidQueryError):
+    """Custom exception class for skipped lines in SSH known hosts files."""
+
+
+class KnownHostsQuery(BaseQueryAggregator):
+    """KnownHostsQuery query builder.
+
+    The ``knownhosts`` backend allow to use Cumin taking advantage of existing 
SSH known hosts files.
+    It allow to write arbitrarily complex queries with subgroups and boolean 
operators, but each item must be either
+    the hostname itself, or using host expansion with the powerful 
:py:class:`ClusterShell.NodeSet.NodeSet` syntax.
+
+    The typical use case for the ``knownhosts`` backend is when the known 
hosts file(s) are generated and kept updated
+    by some external configuration manager or tool that is not yet supported 
as a backend for Cumin. It can also work
+    as a fallback backend in case the primary backend is unavailable but the 
known hosts file(s) are still up to date.
+    """
+
+    grammar = grammar()
+    """:py:class:`pyparsing.ParserElement`: load the grammar parser only once 
in a singleton-like way."""
+
+    def __init__(self, config):
+        """Known hosts query constructor, initialize the known hosts.
+
+        :Parameters:
+            according to parent :py:meth:`cumin.backends.BaseQuery.__init__`.
+        """
+        super().__init__(config)
+
+        self.known_hosts = set()
+        self.resolver = None
+
+    def _build(self, query_string):
+        """Override parent method to lazy-loading the known hosts if needed.
+
+        :Parameters:
+            according to parent :py:meth:`cumin.backends.BaseQuery._build`.
+        """
+        if not self.known_hosts:
+            self._load_known_hosts()
+
+        if self.resolver is None:
+            source = GroupSource('all', allgroups='\n'.join(self.known_hosts))
+            self.resolver = GroupResolver(default_source=source)
+
+        super()._build(query_string)
+
+    def _execute(self):
+        """Override parent method to ensure to return only existing hosts.
+
+        :Parameters:
+            according to parent :py:meth:`cumin.backends.BaseQuery._execute`.
+        """
+        hosts = super()._execute()
+        return hosts & NodeSet('*', resolver=self.resolver)
+
+    def _parse_token(self, token):
+        """Concrete implementation of parent abstract method.
+
+        :Parameters:
+            according to parent 
:py:meth:`cumin.backends.BaseQueryAggregator._parse_token`.
+        """
+        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: %s | %s', token_dict, token)
+
+        if 'hosts' in token_dict:
+            element = self._get_stack_element()
+            element['hosts'] = NodeSet.fromlist(token_dict['hosts'], 
resolver=self.resolver)
+            if 'bool' in token_dict:
+                element['bool'] = token_dict['bool']
+            self.stack_pointer['children'].append(element)
+        elif 'open_subgroup' in token_dict and 'close_subgroup' in token_dict:
+            self._open_subgroup()
+            if 'bool' in token_dict:
+                self.stack_pointer['bool'] = token_dict['bool']
+            for subtoken in token:
+                if isinstance(subtoken, str):  # Grammar literals, boolean 
operators and parentheses
+                    continue
+                self._parse_token(subtoken)
+            self._close_subgroup()
+        else:  # pragma: no cover - this should never happen
+            raise InvalidQueryError('Got unexpected token: 
{token}'.format(token=token))
+
+    def _load_known_hosts(self):
+        """Load all known hosts file listed in the configuration."""
+        config = self.config.get('knownhosts', {})
+        known_hosts_filenames = config.get('files', [])
+
+        for filename in known_hosts_filenames:
+            hosts = set()
+            with open(filename, 'r') as known_hosts_file:
+                for lineno, line in enumerate(known_hosts_file, 1):
+                    try:
+                        found, skipped = 
KnownHostsQuery.parse_known_hosts_line(line)
+                        if skipped:
+                            self.logger.trace("Skipped patterns at line %d in 
known hosts file '%s': %s",
+                                              lineno, filename, ', 
'.join(skipped))
+                        hosts.update(found)
+                    except KnownHostsLineError as e:
+                        self.logger.warning("Discarded invalid line %d (%s) in 
known hosts file '%s': %s",
+                                            lineno, e, filename, line)
+                    except KnownHostsSkippedLineError as e:
+                        self.logger.trace("Skipped %s line %d in known hosts 
file '%s': %s", e, lineno, filename, line)
+
+            self.logger.debug("Loaded %d hosts from '%s'", len(hosts), 
filename)
+            self.known_hosts.update(hosts)
+
+    @staticmethod
+    def parse_known_hosts_line(line):
+        """Parse an SSH known hosts formatted line and extract the valid 
hostnames.
+
+        See the ``SSH_KNOWN_HOSTS FILE FORMAT` in ``man sshd`` for the details 
of the file format.
+
+        Arguments:
+            line (str): the line to parse.
+
+        Raises:
+            KnownHostsSkippedLineError: if the line is skipped.
+            KnownHostsLineError: if unable to parse the line.
+
+        Returns:
+            set: a set with the hostnames found in the given line.
+
+        """
+        line = line.strip()
+        if not line:
+            raise KnownHostsSkippedLineError('empty line')
+
+        if line[0] == '#':
+            raise KnownHostsSkippedLineError('comment')
+
+        if line[0] == '|':
+            raise KnownHostsSkippedLineError('hashed')
+
+        fields = line.split()
+        if len(fields) < 3:
+            raise KnownHostsLineError('not enough fields')
+
+        if line[0] == '@':
+            if len(fields) < 4:
+                raise KnownHostsLineError('not enough fields')
+
+            if fields[0] == '@cert-authority':
+                line_hosts = fields[1]
+            elif fields[0] == '@revoked':
+                raise KnownHostsSkippedLineError('revoked')
+            else:
+                raise KnownHostsLineError('unknown marker')
+        else:
+            line_hosts = fields[0]
+
+        return KnownHostsQuery.parse_line_hosts(line_hosts)
+
+    @staticmethod
+    def parse_line_hosts(line_hosts):
+        """Parse a comma-separated hostnamed from an SSH known hosts formatted 
line and extract the valid hostnames.
+
+        Arguments:
+            line_hosts (str): the hostnames to parse.
+
+        Returns:
+            tuple: a tuple with two sets, the hostnames found in the given 
line and the hostnames skipped.
+
+        """
+        hosts = set()
+        skipped = set()
+        for host in line_hosts.split(','):
+            if not host:
+                continue
+
+            if host[0] == '!':
+                host = host[1:]
+
+            if host[0] == '[':
+                host = host[1:].split(']')[0]
+
+            if '*' in host or '?' in host:
+                skipped.add(host)
+            else:
+                try:
+                    ipaddress.ip_address(host)
+                    skipped.add(host)
+                except ValueError:
+                    hosts.add(host)  # Add hostnames, skip IP addresses
+
+        return hosts, skipped
+
+
+GRAMMAR_PREFIX = 'K'
+""":py:class:`str`: the prefix associate to this grammar, to register this 
backend into the general grammar.
+Required by the backend auto-loader in 
:py:meth:`cumin.grammar.get_registered_backends`."""
+
+query_class = KnownHostsQuery  # pylint: disable=invalid-name
+"""Required by the backend auto-loader in 
:py:meth:`cumin.grammar.get_registered_backends`."""
diff --git a/cumin/tests/fixtures/backends/grammars/knownhosts_invalid.txt 
b/cumin/tests/fixtures/backends/grammars/knownhosts_invalid.txt
new file mode 100644
index 0000000..dbb548d
--- /dev/null
+++ b/cumin/tests/fixtures/backends/grammars/knownhosts_invalid.txt
@@ -0,0 +1,7 @@
+# Invalid grammars
+some+host
+not host1
+host1 or not host2
+host1 and (not host2)
+Z:category
+Z:category = value
diff --git a/cumin/tests/fixtures/backends/grammars/knownhosts_valid.txt 
b/cumin/tests/fixtures/backends/grammars/knownhosts_valid.txt
new file mode 100644
index 0000000..f47c182
--- /dev/null
+++ b/cumin/tests/fixtures/backends/grammars/knownhosts_valid.txt
@@ -0,0 +1,14 @@
+# Valid grammars
+hostname
+host-name
+host_name.domain
+hostname and host_name.domain.tld
+host1 or host2
+host1 and host2
+host1 and not host2
+host10[10-20,30-50]
+host10[10-20,30-50]*
+host?0[10-20,30-50]*
+(hostname.domain.tld)
+(host1 or host2) and host1
+((host1[0-9] or host01) and host[01-10])
diff --git a/cumin/tests/fixtures/backends/knownhosts.txt 
b/cumin/tests/fixtures/backends/knownhosts.txt
new file mode 100644
index 0000000..2b7e3d5
--- /dev/null
+++ b/cumin/tests/fixtures/backends/knownhosts.txt
@@ -0,0 +1,50 @@
+# This is a comment and should be ignored, like empty lines
+
+# Hostname only
+host1.domain ecdsa-sha2-nistp256 AAAA...=
+# IPv4 only
+127.0.1.2 ecdsa-sha2-nistp256 AAAA...=
+# IPv6 only
+fe80::3 ecdsa-sha2-nistp256 AAAA...=
+# Hostname and IPv4
+host4.domain,127.0.1.7 ecdsa-sha2-nistp256 AAAA...=
+# Hostname and IPv6
+host5.domain,fe80::9 ecdsa-sha2-nistp256 AAAA...=
+# IPv4 and IPv6
+127.0.1.6,fe80::11 ecdsa-sha2-nistp256 AAAA...=
+# Hostname, IPv4 and IPv6
+host7.domain,127.0.1.13,fe80::13 ecdsa-sha2-nistp256 AAAA...=
+# CA marker
+@cert-authority host8.domain ssh-rsa AAAA...=
+# Revoked marker
+@revoked host9.domain ssh-rsa AAAA...=
+# Hashed line
+|1|HaSh=|HaSh= ecdsa-sha2-nistp256 AAAA...=
+# Not enough fields
+host10.domain ssh-rsa
+# Not enough fields with marker
+@cert-authority host11.domain ssh-rsa
+# Unknown marker
+@marker host12.domain ssh-rsa AAAA...=
+# Patterns only
+*.domain ecdsa-sha2-nistp256 AAAA...=
+host?.domain ecdsa-sha2-nistp256 AAAA...=
+# Hostname and pattern
+host13.domain,*.otherdomain ecdsa-sha2-nistp256 AAAA...=
+*.otherdomain,host14.domain ecdsa-sha2-nistp256 AAAA...=
+# IPv4 and pattern
+127.0.1.2,*.otherdomain ecdsa-sha2-nistp256 AAAA...=
+*.otherdomain,127.0.1.2 ecdsa-sha2-nistp256 AAAA...=
+# IPv6 and pattern
+fe80::3,*.otherdomain ecdsa-sha2-nistp256 AAAA...=
+*.otherdomain,fe80::3 ecdsa-sha2-nistp256 AAAA...=
+# Hostname, IPv4 and pattern
+host4.domain,*.otherdomain,127.0.1.7 ecdsa-sha2-nistp256 AAAA...=
+# Hostname, IPv6 and pattern
+host5.domain,*.otherdomain,fe80::9 ecdsa-sha2-nistp256 AAAA...=
+# IPv4, IPv6 and pattern
+127.0.1.6,*.otherdomain,fe80::11 ecdsa-sha2-nistp256 AAAA...=
+# Hostname, IPv4, IPv6 and pattern
+host7.domain,127.0.1.13,*.otherdomain,fe80::13 ecdsa-sha2-nistp256 AAAA...=
+
+invalid line
diff --git a/cumin/tests/fixtures/backends/knownhosts_man.txt 
b/cumin/tests/fixtures/backends/knownhosts_man.txt
new file mode 100644
index 0000000..e4dfbdf
--- /dev/null
+++ b/cumin/tests/fixtures/backends/knownhosts_man.txt
@@ -0,0 +1,9 @@
+# Comments allowed at start of line
+closenet,192.0.2.53 1024 37 159...93 closenet.example.net
+cvs.example.net,192.0.2.10 ssh-rsa AAAA1234.....=
+# A hashed hostname
+|1|JfKTdBh7rNbXkVAQCRp4OQoPfmI=|USECr3SWf1JUPsms5AqfD5QfxkM= ssh-rsa 
AAAA1234.....=
+# A revoked key
+@revoked * ssh-rsa AAAAB5W...
+# A CA key, accepted for any host in *.mydomain.com or *.mydomain.org
+@cert-authority *.mydomain.org,*.mydomain.com ssh-rsa AAAAB5W...
diff --git a/cumin/tests/unit/backends/test_knownhosts.py 
b/cumin/tests/unit/backends/test_knownhosts.py
new file mode 100644
index 0000000..1375307
--- /dev/null
+++ b/cumin/tests/unit/backends/test_knownhosts.py
@@ -0,0 +1,192 @@
+"""Known hosts backend tests."""
+import os
+
+import pytest
+
+from ClusterShell.NodeSet import NodeSet, RESOLVER_NOGROUP
+
+from cumin.backends import BaseQuery
+from cumin.backends.knownhosts import KnownHostsLineError, KnownHostsQuery, 
KnownHostsSkippedLineError, query_class
+from cumin.tests import get_fixture_path
+
+
+def test_knownhosts_query_class():
+    """An instance of query_class should be an instance of BaseQuery."""
+    query = query_class({})
+    assert isinstance(query, BaseQuery)
+
+
+class TestKnownhostsQuery(object):
+    """Knownhosts backend query test class."""
+
+    def setup_method(self, _):
+        """Set up an instance of KnownHostsQuery for each test."""
+        # pylint: disable=attribute-defined-outside-init
+        self.query = KnownHostsQuery({
+            'knownhosts': {'files': [
+                get_fixture_path(os.path.join('backends', 'knownhosts.txt')),
+                get_fixture_path(os.path.join('backends', 
'knownhosts_man.txt')),
+            ]}})
+        self.no_query = KnownHostsQuery({})
+        self.no_hosts = NodeSet(resolver=RESOLVER_NOGROUP)
+        self.domain_hosts = NodeSet('host[1,4-5,7-8,13-14].domain', 
resolver=RESOLVER_NOGROUP)
+        self.all_hosts = self.domain_hosts | 
NodeSet('closenet,cvs.example.net', resolver=RESOLVER_NOGROUP)
+
+    def test_instantiation(self):
+        """An instance of KnownHostsQuery should be an instance of 
BaseQuery."""
+        assert isinstance(self.query, BaseQuery)
+        assert 'knownhosts' in self.query.config
+
+    def test_execute(self):
+        """Calling execute() with one host should return it."""
+        assert self.query.execute('host1.domain') == NodeSet('host1.domain', 
resolver=RESOLVER_NOGROUP)
+
+    def test_execute_non_existent(self):
+        """Calling execute() with one host that doens't exists should return 
no hosts."""
+        assert self.query.execute('nohost1.domain') == self.no_hosts
+
+    def test_execute_or(self):
+        """Calling execute() with two hosts in 'or' should return both 
hosts."""
+        expected = NodeSet('host[1,4].domain', resolver=RESOLVER_NOGROUP)
+        assert self.query.execute('host1.domain or host4.domain') == expected
+
+    def test_execute_and(self):
+        """Calling execute() with two hosts in 'and' should return no hosts."""
+        assert self.query.execute('host1.domain and host2.domain') == 
self.no_hosts
+
+    def test_execute_and_not(self):
+        """Calling execute() with two hosts with 'and not' should return the 
first host."""
+        expected = NodeSet('host1.domain', resolver=RESOLVER_NOGROUP)
+        assert self.query.execute('host1.domain and not host2.domain') == 
expected
+
+    def test_execute_xor(self):
+        """Calling execute() with two host groups with 'xor' should return the 
hosts that are not in both groups."""
+        expected = NodeSet('host[1,7-8].domain', resolver=RESOLVER_NOGROUP)
+        assert self.query.execute('host[1-8].domain xor host[4-6].domain') == 
expected
+
+    def test_execute_complex(self):
+        """Calling execute() with a complex query should return the matching 
hosts."""
+        expected = NodeSet('host[1,5,8].domain', resolver=RESOLVER_NOGROUP)
+        assert self.query.execute('host1.domain or (host[5-9].domain and not 
host7.domain)') == expected
+
+        expected = NodeSet('host1.domain', resolver=RESOLVER_NOGROUP)
+        assert self.query.execute(
+            '(host1.domain or host[2-5].domain) and not (host[3-9].domain or 
host2.domain)') == expected
+
+    def test_execute_all(self):
+        """Calling execute() with broader matching should return all hosts."""
+        assert self.query.execute('*') == self.all_hosts
+        assert self.query.execute('host[1-100].domain') == self.domain_hosts
+        assert self.query.execute('host[1-100].domai?') == self.domain_hosts
+        assert self.query.execute('host[1-100].*') == self.domain_hosts
+
+    def test_execute_no_hosts(self):
+        """Calling execute() without any known hosts to load should return no 
hosts."""
+        assert self.no_query.execute('host1.domain') == self.no_hosts
+        assert self.no_query.execute('*') == self.no_hosts
+
+
+def test_parse_line_empty():
+    """Empty lines should raise KnownHostsSkippedLineError."""
+    with pytest.raises(KnownHostsSkippedLineError, match='empty line'):
+        KnownHostsQuery.parse_known_hosts_line('')
+    with pytest.raises(KnownHostsSkippedLineError, match='empty line'):
+        KnownHostsQuery.parse_known_hosts_line('\n')
+
+
+def test_parse_line_comment():
+    """Comment lines should raise KnownHostsSkippedLineError."""
+    with pytest.raises(KnownHostsSkippedLineError, match='comment'):
+        KnownHostsQuery.parse_known_hosts_line('# comment')
+
+
+def test_parse_line_hashed():
+    """Hashed lines should raise KnownHostsSkippedLineError."""
+    with pytest.raises(KnownHostsSkippedLineError, match='hashed'):
+        KnownHostsQuery.parse_known_hosts_line('|1|HaSh=|HaSh= 
ecdsa-sha2-nistp256 AAAA...=')
+
+
+def test_parse_line_no_fields():
+    """Lines without enough fields should raise KnownHostsLineError."""
+    with pytest.raises(KnownHostsLineError, match='not enough fields'):
+        KnownHostsQuery.parse_known_hosts_line('host1 ssh-rsa')
+
+
+def test_parse_line_no_fields_mark():
+    """Lines with a marker but without enough fields should raise 
KnownHostsLineError."""
+    with pytest.raises(KnownHostsLineError, match='not enough fields'):
+        KnownHostsQuery.parse_known_hosts_line('@marker host1 ssh-rsa')
+
+
+def test_parse_line_revoked():
+    """Lines with a revoked marker should raise KnownHostsSkippedLineError."""
+    with pytest.raises(KnownHostsSkippedLineError, match='revoked'):
+        KnownHostsQuery.parse_known_hosts_line('@revoked host1 
ecdsa-sha2-nistp256 AAAA...=')
+
+
+def test_parse_line_unknown_marker():
+    """Lines with an unknown marker should raise KnownHostsLineError."""
+    with pytest.raises(KnownHostsLineError, match='unknown marker'):
+        KnownHostsQuery.parse_known_hosts_line('@marker host1 
ecdsa-sha2-nistp256 AAAA...=')
+
+
+def test_parse_line_ca():
+    """Lines with a cert-authority marker should parse the hostnames."""
+    expected = ({'host1'}, set())
+    assert KnownHostsQuery.parse_known_hosts_line('@cert-authority host1 
ecdsa-sha2-nistp256 AAAA...=') == expected
+
+
+def test_parse_line():
+    """With a standard line should parse the hostnames."""
+    assert KnownHostsQuery.parse_known_hosts_line('host1 ecdsa-sha2-nistp256 
AAAA...=') == ({'host1'}, set())
+
+
+def test_parse_line_hosts_empty():
+    """Empty line hosts should be skipped."""
+    assert KnownHostsQuery.parse_line_hosts(',') == (set(), set())
+    assert KnownHostsQuery.parse_line_hosts('host1,,') == ({'host1'}, set())
+
+
+def test_parse_line_hosts_negated():
+    """Negated line hosts should remove the negation."""
+    assert KnownHostsQuery.parse_line_hosts('!host1') == ({'host1'}, set())
+    expected = ({'host1', 'host2'}, set())
+    assert KnownHostsQuery.parse_line_hosts('!host1,host2') == expected
+    assert KnownHostsQuery.parse_line_hosts('host1,!host2') == expected
+    assert KnownHostsQuery.parse_line_hosts('!host1,!host2') == expected
+
+
+def test_parse_line_hosts_port():
+    """Line hosts with custom ports should remove the additional syntax."""
+    assert KnownHostsQuery.parse_line_hosts('[host1]:2222') == ({'host1'}, 
set())
+    expected = ({'host1', 'host2'}, set())
+    assert KnownHostsQuery.parse_line_hosts('[host1]:2222,host2') == expected
+    assert KnownHostsQuery.parse_line_hosts('host1,[host2]:2222') == expected
+    assert KnownHostsQuery.parse_line_hosts('[host1]:2222,[host2]:2222') == 
expected
+
+
+def test_parse_line_hosts_neg_port():
+    """Line hosts with custom ports and negated entries should remove the 
additional syntax."""
+    assert KnownHostsQuery.parse_line_hosts('![host1]:2222') == ({'host1'}, 
set())
+    expected = ({'host1', 'host2'}, set())
+    assert KnownHostsQuery.parse_line_hosts('![host1]:2222,!host2') == expected
+    assert KnownHostsQuery.parse_line_hosts('!host1,![host2]:2222') == expected
+    assert KnownHostsQuery.parse_line_hosts('![host1]:2222,![host2]:2222') == 
expected
+
+
+def test_parse_line_hosts_patterns():
+    """Line hosts with patterns should skip the patterns entries."""
+    assert KnownHostsQuery.parse_line_hosts('host?') == (set(), {'host?'})
+    assert KnownHostsQuery.parse_line_hosts('host*') == (set(), {'host*'})
+    assert KnownHostsQuery.parse_line_hosts('host?,host2') == ({'host2'}, 
{'host?'})
+    assert KnownHostsQuery.parse_line_hosts('host*,host2') == ({'host2'}, 
{'host*'})
+    assert KnownHostsQuery.parse_line_hosts('host*,host2,host?') == 
({'host2'}, {'host?', 'host*'})
+
+
+def test_parse_line_hosts_ips():
+    """Line hosts with IPs should skip the IP entries."""
+    assert KnownHostsQuery.parse_line_hosts('127.0.1.1') == (set(), 
{'127.0.1.1'})
+    assert KnownHostsQuery.parse_line_hosts('fe80::1') == (set(), {'fe80::1'})
+    assert KnownHostsQuery.parse_line_hosts('host1,127.0.1.1') == ({'host1'}, 
{'127.0.1.1'})
+    assert KnownHostsQuery.parse_line_hosts('host1,fe80::1') == ({'host1'}, 
{'fe80::1'})
+    assert KnownHostsQuery.parse_line_hosts('host1,127.0.1.1,fe80::1') == 
({'host1'}, {'127.0.1.1', 'fe80::1'})
diff --git a/doc/examples/config.yaml b/doc/examples/config.yaml
index b546b49..6f67920 100644
--- a/doc/examples/config.yaml
+++ b/doc/examples/config.yaml
@@ -31,6 +31,10 @@
     query_params:
         project: project_name  # Parameter name: parameter value
 
+knownhosts:
+    files:  # List of SSH known hosts files to load
+        - /path/to/known_hosts
+
 # Transport-specific configuration
 clustershell:
     ssh_options:  # SSH options passed to ClusterShell [optional]

-- 
To view, visit https://gerrit.wikimedia.org/r/405719
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: Ic0f6b89c4c08e65dfebb6a61bab7b7831188b2ca
Gerrit-PatchSet: 1
Gerrit-Project: operations/software/cumin
Gerrit-Branch: master
Gerrit-Owner: Volans <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to