Allow running satori on localhost
Still needs unittests Change-Id: Ia6bf6f5d1e4bcadb414eb79bb9673fbc60d1c9a6 Implements: blueprint satori-localhost
This commit is contained in:
parent
3e245d4849
commit
01ad34c89f
|
@ -1,3 +1,4 @@
|
|||
ipaddress>=1.0.6 # in stdlib as of python3.3
|
||||
iso8601>=0.1.9
|
||||
Jinja2
|
||||
# python3 branch of paramiko
|
||||
|
|
|
@ -0,0 +1,152 @@
|
|||
# Copyright 2012-2013 OpenStack Foundation
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
|
||||
"""Shell classes for executing commands on a system.
|
||||
|
||||
Execute commands over ssh or using python subprocess module.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from satori import ssh
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShellMixin(object):
|
||||
|
||||
"""Handle platform detection and define execute command."""
|
||||
|
||||
def execute(self, command, wd=None, with_exit_code=None):
|
||||
"""Execute a (shell) command on the target.
|
||||
|
||||
:param command: Shell command to be executed
|
||||
:param with_exit_code: Include the exit_code in the return body.
|
||||
:param wd: The child's current directory will be changed
|
||||
to `wd` before it is executed. Note that this
|
||||
directory is not considered when searching the
|
||||
executable, so you can't specify the program's
|
||||
path relative to this argument
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def platform_info(self):
|
||||
"""Provide distro, version, architecture."""
|
||||
pass
|
||||
|
||||
def is_debian(self):
|
||||
"""Return a boolean indicating whether the system is debian based.
|
||||
|
||||
Uses the platform_info property.
|
||||
"""
|
||||
return self.platform_info['dist'].lower() in ['debian', 'ubuntu']
|
||||
|
||||
def is_fedora(self):
|
||||
"""Return a boolean indicating whether the system in fedora based.
|
||||
|
||||
Uses the platform info property.
|
||||
"""
|
||||
return (self.platform_info['dist'].lower() in
|
||||
['redhat', 'centos', 'fedora', 'el'])
|
||||
|
||||
|
||||
class LocalShell(ShellMixin):
|
||||
|
||||
"""Execute shell commands on local machine."""
|
||||
|
||||
def __init__(self, user=None, password=None, interactive=False):
|
||||
"""An interface for executing shell commands locally.
|
||||
|
||||
:param user: The user to execute the command as.
|
||||
Defaults to the current user.
|
||||
:param password: The password for `user`
|
||||
:param interactive: If true, prompt for password if missing.
|
||||
|
||||
"""
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.interactive = interactive
|
||||
# TODO(samstav): Implement handle_password_prompt for popen
|
||||
|
||||
@property
|
||||
def platform_info(self):
|
||||
"""Return distro, version, and system architecture."""
|
||||
return list(platform.dist() + (platform.machine(),))
|
||||
|
||||
def execute(self, command, wd=None, with_exit_code=None):
|
||||
"""Execute a command (containing no shell operators) locally."""
|
||||
spipe = subprocess.PIPE
|
||||
|
||||
cmd = shlex.split(command)
|
||||
LOG.debug("Executing `%s` on local machine", command)
|
||||
result = subprocess.Popen(
|
||||
cmd, stdout=spipe, stderr=spipe, cwd=wd)
|
||||
out, err = result.communicate()
|
||||
resultdict = {
|
||||
'stdout': out.strip(),
|
||||
'stderr': err.strip(),
|
||||
}
|
||||
if with_exit_code:
|
||||
resultdict.update({'exit_code': result.returncode})
|
||||
return resultdict
|
||||
|
||||
|
||||
class RemoteShell(ShellMixin):
|
||||
|
||||
"""Execute shell commands on a remote machine over ssh."""
|
||||
|
||||
def __init__(self, address, **kwargs):
|
||||
"""An interface for executing shell commands on remote machines.
|
||||
|
||||
:param str host: The ip address or host name of the server
|
||||
to connect to
|
||||
:param str password: A password to use for authentication
|
||||
or for unlocking a private key
|
||||
:param username: The username to authenticate as
|
||||
:param private_key: Private SSH Key string to use
|
||||
(instead of using a filename)
|
||||
:param key_filename: a private key filename (path)
|
||||
:param port: tcp/ip port to use (defaults to 22)
|
||||
:param float timeout: an optional timeout (in seconds) for the
|
||||
TCP connection
|
||||
:param socket proxy: an existing SSH instance to use
|
||||
for proxying
|
||||
:param dict options: A dictionary used to set ssh options
|
||||
(when proxying).
|
||||
e.g. for `ssh -o StrictHostKeyChecking=no`,
|
||||
you would provide
|
||||
(.., options={'StrictHostKeyChecking': 'no'})
|
||||
Conversion of booleans is also supported,
|
||||
(.., options={'StrictHostKeyChecking': False})
|
||||
is equivalent.
|
||||
:keyword interactive: If true, prompt for password if missing.
|
||||
"""
|
||||
self.sshclient = ssh.connect(address, **kwargs)
|
||||
self.host = self.sshclient.host
|
||||
self.port = self.sshclient.port
|
||||
|
||||
@property
|
||||
def platform_info(self):
|
||||
"""Return distro, version, architecture."""
|
||||
return self.sshclient.platform_info
|
||||
|
||||
def execute(self, command, wd=None, with_exit_code=None):
|
||||
"""Execute given command over ssh."""
|
||||
return self.sshclient.remote_execute(
|
||||
command, wd=wd, with_exit_code=with_exit_code)
|
|
@ -23,7 +23,9 @@ Example usage:
|
|||
|
||||
from __future__ import print_function
|
||||
|
||||
import ipaddress as ipaddress_module
|
||||
from novaclient.v1_1 import client
|
||||
from pythonwhois import shared
|
||||
import six
|
||||
|
||||
from satori import dns
|
||||
|
@ -37,7 +39,17 @@ def run(address, config, interactive=False):
|
|||
ipaddress = address
|
||||
else:
|
||||
ipaddress = dns.resolve_hostname(address)
|
||||
results['domain'] = dns.domain_info(address)
|
||||
#TODO(sam): Use ipaddress.ip_address.is_global
|
||||
# " .is_private
|
||||
# " .is_unspecified
|
||||
# " .is_multicast
|
||||
# To determine address "type"
|
||||
if not ipaddress_module.ip_address(unicode(ipaddress)).is_loopback:
|
||||
try:
|
||||
results['domain'] = dns.domain_info(address)
|
||||
except shared.WhoisException as exc:
|
||||
results['domain'] = str(exc)
|
||||
|
||||
results['address'] = ipaddress
|
||||
|
||||
results['host'] = host = {'type': 'Undetermined'}
|
||||
|
|
|
@ -340,7 +340,8 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
|
|||
|
||||
return False
|
||||
|
||||
def remote_execute(self, command, with_exit_code=False, get_pty=False):
|
||||
def remote_execute(self, command, with_exit_code=False,
|
||||
get_pty=False, wd=None):
|
||||
"""Execute an ssh command on a remote host.
|
||||
|
||||
Tries cert auth first and falls back
|
||||
|
@ -348,11 +349,20 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
|
|||
|
||||
:param command: Shell command to be executed by this function.
|
||||
:param with_exit_code: Include the exit_code in the return body.
|
||||
:param wd: The child's current directory will be changed
|
||||
to `wd` before it is executed. Note that this
|
||||
directory is not considered when searching the
|
||||
executable, so you can't specify the program's
|
||||
path relative to this argument
|
||||
:param get_pty: Request a pseudo-terminal from the server.
|
||||
|
||||
:returns: a dict with stdin, stdout,
|
||||
and (optionally) the exit code of the call.
|
||||
"""
|
||||
if wd:
|
||||
prefix = "cd %s && " % wd
|
||||
command = prefix + command
|
||||
|
||||
LOG.debug("Executing '%s' on ssh://%s@%s:%s.",
|
||||
command, self.username, self.host, self.port)
|
||||
try:
|
||||
|
|
|
@ -10,15 +10,18 @@
|
|||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
# pylint: disable=W0622
|
||||
"""Ohai Solo Data Plane Discovery Module."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import ipaddress as ipaddress_module
|
||||
import six
|
||||
|
||||
from satori import bash
|
||||
from satori import errors
|
||||
from satori import ssh
|
||||
from satori import utils
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
if six.PY3:
|
||||
|
@ -34,17 +37,26 @@ def get_systeminfo(ipaddress, config, interactive=False):
|
|||
:param config: arguments and configuration suppplied to satori.
|
||||
:keyword interactive: whether to prompt the user for information.
|
||||
"""
|
||||
ssh_client = ssh.connect(ipaddress, username=config.host_username,
|
||||
private_key=config.host_key,
|
||||
interactive=interactive)
|
||||
install_remote(ssh_client)
|
||||
return system_info(ssh_client)
|
||||
if (ipaddress in utils.get_local_ips() or
|
||||
ipaddress_module.ip_address(unicode(ipaddress)).is_loopback):
|
||||
|
||||
client = bash.LocalShell()
|
||||
client.host = "localhost"
|
||||
client.port = 0
|
||||
|
||||
else:
|
||||
client = bash.RemoteShell(ipaddress, username=config.host_username,
|
||||
private_key=config.host_key,
|
||||
interactive=interactive)
|
||||
|
||||
install_remote(client)
|
||||
return system_info(client)
|
||||
|
||||
|
||||
def system_info(ssh_client):
|
||||
def system_info(client):
|
||||
"""Run ohai-solo on a remote system and gather the output.
|
||||
|
||||
:param ssh_client: :class:`ssh.SSH` instance
|
||||
:param client: :class:`ssh.SSH` instance
|
||||
:returns: dict -- system information from ohai-solo
|
||||
:raises: SystemInfoCommandMissing, SystemInfoCommandOld, SystemInfoNotJson
|
||||
SystemInfoMissingJson
|
||||
|
@ -54,13 +66,13 @@ def system_info(ssh_client):
|
|||
SystemInfoNotJson if `ohai` does not return valid JSON.
|
||||
SystemInfoMissingJson if `ohai` does not return any JSON.
|
||||
"""
|
||||
output = ssh_client.remote_execute("sudo -i ohai-solo")
|
||||
output = client.execute("sudo -i ohai-solo")
|
||||
not_found_msgs = ["command not found", "Could not find ohai"]
|
||||
if any(m in k for m in not_found_msgs
|
||||
for k in list(output.values()) if isinstance(k, six.string_types)):
|
||||
LOG.warning("SystemInfoCommandMissing on host: [%s]", ssh_client.host)
|
||||
LOG.warning("SystemInfoCommandMissing on host: [%s]", client.host)
|
||||
raise errors.SystemInfoCommandMissing("ohai-solo missing on %s",
|
||||
ssh_client.host)
|
||||
client.host)
|
||||
unicode_output = unicode(output['stdout'], errors='replace')
|
||||
try:
|
||||
results = json.loads(unicode_output)
|
||||
|
@ -83,21 +95,21 @@ def is_fedora(platform):
|
|||
return platform['dist'].lower() in ['redhat', 'centos', 'fedora', 'el']
|
||||
|
||||
|
||||
def install_remote(ssh_client):
|
||||
def install_remote(client):
|
||||
"""Install ohai-solo on remote system."""
|
||||
LOG.info("Installing (or updating) ohai-solo on device %s at %s:%d",
|
||||
ssh_client.host, ssh_client.host, ssh_client.port)
|
||||
client.host, client.host, client.port)
|
||||
# Download to host
|
||||
command = "cd /tmp && sudo wget -N http://ohai.rax.io/install.sh"
|
||||
ssh_client.remote_execute(command)
|
||||
command = "sudo wget -N http://ohai.rax.io/install.sh"
|
||||
client.execute(command, wd='/tmp')
|
||||
|
||||
# Run install
|
||||
command = "cd /tmp && bash install.sh"
|
||||
output = ssh_client.remote_execute(command, with_exit_code=True)
|
||||
command = "sudo bash install.sh"
|
||||
output = client.execute(command, wd='/tmp', with_exit_code=True)
|
||||
|
||||
# Be a good citizen and clean up your tmp data
|
||||
command = "cd /tmp && rm install.sh"
|
||||
ssh_client.remote_execute(command)
|
||||
command = "sudo rm install.sh"
|
||||
client.execute(command, wd='/tmp')
|
||||
|
||||
# Process install command output
|
||||
if output['exit_code'] != 0:
|
||||
|
@ -106,7 +118,7 @@ def install_remote(ssh_client):
|
|||
return output
|
||||
|
||||
|
||||
def remove_remote(ssh_client):
|
||||
def remove_remote(client):
|
||||
"""Remove ohai-solo from specifc remote system.
|
||||
|
||||
Currently supports:
|
||||
|
@ -115,7 +127,7 @@ def remove_remote(ssh_client):
|
|||
- redhat [5.x, 6.x]
|
||||
- centos [5.x, 6.x]
|
||||
"""
|
||||
platform_info = ssh_client.platform_info
|
||||
platform_info = client.platform_info
|
||||
if is_debian(platform_info):
|
||||
remove = "sudo dpkg --purge ohai-solo"
|
||||
elif is_fedora(platform_info):
|
||||
|
@ -123,8 +135,8 @@ def remove_remote(ssh_client):
|
|||
else:
|
||||
raise errors.UnsupportedPlatform("Unknown distro: %s" %
|
||||
platform_info['dist'])
|
||||
command = "cd /tmp && %s" % remove
|
||||
output = ssh_client.remote_execute(command)
|
||||
command = "%s" % remove
|
||||
output = client.execute(command, wd='/tmp')
|
||||
return output
|
||||
|
||||
|
||||
|
@ -141,6 +153,6 @@ def get_json(data):
|
|||
first = data.index('{')
|
||||
last = data.rindex('}')
|
||||
return data[first:last + 1]
|
||||
except ValueError as e:
|
||||
context = {"ValueError": "%s" % e}
|
||||
except ValueError as exc:
|
||||
context = {"ValueError": "%s" % exc}
|
||||
raise errors.SystemInfoMissingJson(context)
|
||||
|
|
|
@ -23,11 +23,11 @@ from satori.tests import utils
|
|||
|
||||
class TestOhaiSolo(utils.TestCase):
|
||||
|
||||
@mock.patch.object(ohai_solo, 'ssh')
|
||||
@mock.patch.object(ohai_solo, 'bash')
|
||||
@mock.patch.object(ohai_solo, 'system_info')
|
||||
@mock.patch.object(ohai_solo, 'install_remote')
|
||||
def test_connect_and_run(self, mock_install, mock_sysinfo, mock_ssh):
|
||||
address = "123.345.678.0"
|
||||
def test_connect_and_run(self, mock_install, mock_sysinfo, mock_bash):
|
||||
address = "192.0.2.2"
|
||||
config = mock.MagicMock()
|
||||
config.host_key = "foo"
|
||||
config.host_username = "bar"
|
||||
|
@ -35,11 +35,14 @@ class TestOhaiSolo(utils.TestCase):
|
|||
result = ohai_solo.get_systeminfo(address, config)
|
||||
self.assertTrue(result is mock_sysinfo.return_value)
|
||||
|
||||
mock_install.assert_called_once_with(mock_ssh.connect.return_value)
|
||||
mock_ssh.connect.assert_called_with(address, username="bar",
|
||||
private_key="foo",
|
||||
interactive=False)
|
||||
mock_sysinfo.assert_called_with(mock_ssh.connect.return_value)
|
||||
mock_install.assert_called_once_with(
|
||||
mock_bash.RemoteShell.return_value)
|
||||
|
||||
mock_bash.RemoteShell.assert_called_with(
|
||||
address, username="bar",
|
||||
private_key="foo",
|
||||
interactive=False)
|
||||
mock_sysinfo.assert_called_with(mock_bash.RemoteShell.return_value)
|
||||
|
||||
|
||||
class TestOhaiInstall(utils.TestCase):
|
||||
|
@ -47,20 +50,19 @@ class TestOhaiInstall(utils.TestCase):
|
|||
def test_install_remote_fedora(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
response = {'exit_code': 0, 'foo': 'bar'}
|
||||
mock_ssh.remote_execute.return_value = response
|
||||
mock_ssh.execute.return_value = response
|
||||
result = ohai_solo.install_remote(mock_ssh)
|
||||
self.assertEqual(result, response)
|
||||
self.assertEqual(mock_ssh.remote_execute.call_count, 3)
|
||||
mock_ssh.remote_execute.assert_has_calls([
|
||||
mock.call("cd /tmp && sudo wget -N http://ohai.rax.io/install.sh"),
|
||||
mock.call("cd /tmp && bash install.sh", with_exit_code=True),
|
||||
mock.call("cd /tmp && rm install.sh")]
|
||||
)
|
||||
self.assertEqual(mock_ssh.execute.call_count, 3)
|
||||
mock_ssh.execute.assert_has_calls([
|
||||
mock.call('sudo wget -N http://ohai.rax.io/install.sh', wd='/tmp'),
|
||||
mock.call('sudo bash install.sh', wd='/tmp', with_exit_code=True),
|
||||
mock.call('sudo rm install.sh', wd='/tmp')])
|
||||
|
||||
def test_install_remote_failed(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
response = {'exit_code': 1, 'stdout': "", "stderr": "FAIL"}
|
||||
mock_ssh.remote_execute.return_value = response
|
||||
mock_ssh.execute.return_value = response
|
||||
self.assertRaises(errors.SystemInfoCommandInstallFailed,
|
||||
ohai_solo.install_remote, mock_ssh)
|
||||
|
||||
|
@ -75,11 +77,11 @@ class TestOhaiRemove(utils.TestCase):
|
|||
'arch': 'xyz'
|
||||
}
|
||||
response = {'exit_code': 0, 'foo': 'bar'}
|
||||
mock_ssh.remote_execute.return_value = response
|
||||
mock_ssh.execute.return_value = response
|
||||
result = ohai_solo.remove_remote(mock_ssh)
|
||||
self.assertEqual(result, response)
|
||||
mock_ssh.remote_execute.assert_called_once_with(
|
||||
"cd /tmp && sudo yum -y erase ohai-solo")
|
||||
mock_ssh.execute.assert_called_once_with(
|
||||
'sudo yum -y erase ohai-solo', wd='/tmp')
|
||||
|
||||
def test_remove_remote_debian(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
|
@ -89,11 +91,11 @@ class TestOhaiRemove(utils.TestCase):
|
|||
'arch': 'xyz'
|
||||
}
|
||||
response = {'exit_code': 0, 'foo': 'bar'}
|
||||
mock_ssh.remote_execute.return_value = response
|
||||
mock_ssh.execute.return_value = response
|
||||
result = ohai_solo.remove_remote(mock_ssh)
|
||||
self.assertEqual(result, response)
|
||||
mock_ssh.remote_execute.assert_called_once_with(
|
||||
"cd /tmp && sudo dpkg --purge ohai-solo")
|
||||
mock_ssh.execute.assert_called_once_with(
|
||||
'sudo dpkg --purge ohai-solo', wd='/tmp')
|
||||
|
||||
def test_remove_remote_unsupported(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
|
@ -106,27 +108,27 @@ class TestSystemInfo(utils.TestCase):
|
|||
|
||||
def test_system_info(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
mock_ssh.remote_execute.return_value = {
|
||||
mock_ssh.execute.return_value = {
|
||||
'exit_code': 0,
|
||||
'stdout': "{}",
|
||||
'stderr': ""
|
||||
}
|
||||
ohai_solo.system_info(mock_ssh)
|
||||
mock_ssh.remote_execute.assert_called_with("sudo -i ohai-solo")
|
||||
mock_ssh.execute.assert_called_with("sudo -i ohai-solo")
|
||||
|
||||
def test_system_info_with_motd(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
mock_ssh.remote_execute.return_value = {
|
||||
mock_ssh.execute.return_value = {
|
||||
'exit_code': 0,
|
||||
'stdout': "Hello world\n {}",
|
||||
'stderr': ""
|
||||
}
|
||||
ohai_solo.system_info(mock_ssh)
|
||||
mock_ssh.remote_execute.assert_called_with("sudo -i ohai-solo")
|
||||
mock_ssh.execute.assert_called_with("sudo -i ohai-solo")
|
||||
|
||||
def test_system_info_bad_json(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
mock_ssh.remote_execute.return_value = {
|
||||
mock_ssh.execute.return_value = {
|
||||
'exit_code': 0,
|
||||
'stdout': "{Not JSON!}",
|
||||
'stderr': ""
|
||||
|
@ -136,7 +138,7 @@ class TestSystemInfo(utils.TestCase):
|
|||
|
||||
def test_system_info_missing_json(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
mock_ssh.remote_execute.return_value = {
|
||||
mock_ssh.execute.return_value = {
|
||||
'exit_code': 0,
|
||||
'stdout': "No JSON!",
|
||||
'stderr': ""
|
||||
|
@ -146,7 +148,7 @@ class TestSystemInfo(utils.TestCase):
|
|||
|
||||
def test_system_info_command_not_found(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
mock_ssh.remote_execute.return_value = {
|
||||
mock_ssh.execute.return_value = {
|
||||
'exit_code': 1,
|
||||
'stdout': "",
|
||||
'stderr': "ohai-solo command not found"
|
||||
|
@ -156,7 +158,7 @@ class TestSystemInfo(utils.TestCase):
|
|||
|
||||
def test_system_info_could_not_find(self):
|
||||
mock_ssh = mock.MagicMock()
|
||||
mock_ssh.remote_execute.return_value = {
|
||||
mock_ssh.execute.return_value = {
|
||||
'exit_code': 1,
|
||||
'stdout': "",
|
||||
'stderr': "Could not find ohai-solo."
|
||||
|
|
|
@ -108,3 +108,30 @@ def is_valid_ipv6_address(address):
|
|||
def is_valid_ip_address(address):
|
||||
"""Check if the address supplied is a valid IP address."""
|
||||
return is_valid_ipv4_address(address) or is_valid_ipv6_address(address)
|
||||
|
||||
|
||||
def get_local_ips():
|
||||
"""Return local ipaddress(es)."""
|
||||
# pylint: disable=W0703
|
||||
list1 = []
|
||||
list2 = []
|
||||
defaults = ["127.0.0.1", r"fe80::1%lo0"]
|
||||
|
||||
hostname = None
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
except Exception as exc:
|
||||
LOG.debug("Error in gethostbyname_ex: %s", exc)
|
||||
|
||||
try:
|
||||
_, _, addresses = socket.gethostbyname_ex(hostname)
|
||||
list1 = [ip for ip in addresses]
|
||||
except Exception as exc:
|
||||
LOG.debug("Error in gethostbyname_ex: %s", exc)
|
||||
|
||||
try:
|
||||
list2 = [info[4][0] for info in socket.getaddrinfo(hostname, None)]
|
||||
except Exception as exc:
|
||||
LOG.debug("Error in getaddrinfo: %s", exc)
|
||||
|
||||
return list(set(list1 + list2 + defaults))
|
||||
|
|
Loading…
Reference in New Issue