From: Adam Israel Date: Tue, 27 Sep 2016 20:15:59 +0000 (-0400) Subject: Migrate vpe-router layer to new directory structure X-Git-Tag: v2.0.2~7^2~18 X-Git-Url: https://osm.etsi.org/gitweb/?a=commitdiff_plain;h=370aa5bd83ce4622f0d8f6e91ff60e11b0190aa4;p=osm%2Fdevops.git Migrate vpe-router layer to new directory structure Change-Id: I107d48c2977531155072cd3f57bc76f732bb2c50 Signed-off-by: Adam Israel --- diff --git a/vpe-router/actions.yaml b/vpe-router/actions.yaml deleted file mode 100644 index 913cc64e..00000000 --- a/vpe-router/actions.yaml +++ /dev/null @@ -1,96 +0,0 @@ -configure-interface: - description: Configure an ethernet interface. - params: - iface-name: - type: string - description: Device name, e.g. eth1 - cidr: - type: string - description: Network range to assign to the interface - required: [iface-name] -add-corporation: - description: Add a new corporation to the router - params: - domain-name: - type: string - description: Name of the vlan corporation - iface-name: - type: string - description: Device name. eg eth1 - vlan-id: - type: integer - description: The name of the vlan? - cidr: - type: string - description: Network range to assign to the tagged vlan-id - area: - type: string - description: Link State Advertisements (LSA) type - subnet-cidr: - type: string - description: Network range - subnet-area: - type: string - description: Link State Advertisements (LSA) type - required: [domain-name, iface-name, vlan-id, cidr, area, subnet-cidr, subnet-area] -delete-corporation: - description: Remove the corporation from the router completely - params: - domain-name: - type: string - description: The domain of the corporation to remove - cidr: - type: string - description: Network range to assign to the tagged vlan-id - area: - type: string - description: Link State Advertisements (LSA) type - subnet-cidr: - type: string - description: Network range - subnet-area: - type: string - description: Link State Advertisements (LSA) type - required: [domain-name, cidr, area, subnet-cidr, subnet-area] -connect-domains: - description: Connect the router to another router, where the same domain is present - params: - domain-name: - type: string - description: The domain of the coproration to connect - iface-name: - type: string - description: Device name. eg eth1 - tunnel-name: - type: string - description: Name of the tunnel ? - local-ip: - type: string - description: local ip ? - remote-ip: - type: string - description: remote ip ? - tunnel-key: - type: string - description: tunnel key? - internal-local-ip: - type: string - description: internal local ip? - internal-remote-ip: - type: string - description: internal remote ip? - tunnel-type: - type: string - default: gre - description: The type of tunnel to establish. - required: [domain-name, iface-name, tunnel-name, local-ip, remote-ip, tunnel-key, internal-local-ip, internal-remote-ip] -delete-domain-connection: - description: Remove the tunnel to another router where the domain is present. - params: - domain-name: - type: string - description: The domain of the corporation to unlink - tunnel-name: - type: string - description: The name of the tunnel to unlink that the domain-name is attached to - required: [domain-name, tunnel-name] diff --git a/vpe-router/actions/add-corporation b/vpe-router/actions/add-corporation deleted file mode 100755 index c8ab2f80..00000000 --- a/vpe-router/actions/add-corporation +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python3 -import sys -sys.path.append('lib') - -from charms.reactive import main -from charms.reactive import set_state -from charmhelpers.core.hookenv import action_fail - -""" -`set_state` only works here because it's flushed to disk inside the `main()` -loop. remove_state will need to be called inside the action method. -""" -set_state('vpe.add-corporation') - -try: - main() -except Exception as e: - action_fail(repr(e)) diff --git a/vpe-router/actions/configure-interface b/vpe-router/actions/configure-interface deleted file mode 100755 index db9a099c..00000000 --- a/vpe-router/actions/configure-interface +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python3 -import sys -sys.path.append('lib') - -from charms.reactive import main -from charms.reactive import set_state -from charmhelpers.core.hookenv import action_fail - -""" -`set_state` only works here because it's flushed to disk inside the `main()` -loop. remove_state will need to be called inside the action method. -""" -set_state('vpe.configure-interface') - -try: - main() -except Exception as e: - action_fail(repr(e)) diff --git a/vpe-router/actions/connect-domains b/vpe-router/actions/connect-domains deleted file mode 100755 index 48adfc76..00000000 --- a/vpe-router/actions/connect-domains +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 - -# Load modules from $CHARM_DIR/lib -import sys -sys.path.append('lib') - -from charms.reactive import main -from charms.reactive import set_state -from charmhelpers.core.hookenv import action_fail - -""" -`set_state` only works here because it's flushed to disk inside the `main()` -loop. remove_state will need to be called inside the action method. -""" -set_state('vpe.connect-domains') - -try: - main() -except Exception as e: - action_fail(repr(e)) diff --git a/vpe-router/actions/delete-corporation b/vpe-router/actions/delete-corporation deleted file mode 100755 index 0576c082..00000000 --- a/vpe-router/actions/delete-corporation +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -import sys -sys.path.append('lib') - -from charms.reactive import main -from charms.reactive import set_state -from charmhelpers.core.hookenv import action_fail - - -""" -`set_state` only works here because it's flushed to disk inside the `main()` -loop. remove_state will need to be called inside the action method. -""" -set_state('vpe.delete-corporation') - -try: - main() -except Exception as e: - action_fail(repr(e)) diff --git a/vpe-router/actions/delete-domain-connection b/vpe-router/actions/delete-domain-connection deleted file mode 100755 index 5ba05f65..00000000 --- a/vpe-router/actions/delete-domain-connection +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -import sys -sys.path.append('lib') - -from charms.reactive import main -from charms.reactive import set_state -from charmhelpers.core.hookenv import action_fail - - -""" -`set_state` only works here because it's flushed to disk inside the `main()` -loop. remove_state will need to be called inside the action method. -""" -set_state('vpe.delete-domain-connection') - -try: - main() -except Exception as e: - action_fail(repr(e)) diff --git a/vpe-router/config.yaml b/vpe-router/config.yaml deleted file mode 100644 index 562515fa..00000000 --- a/vpe-router/config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -options: - vpe-router: - default: - type: string - description: Hostname or IP of the vpe router to connect to - user: - type: string - default: root - description: Username for VPE Router - pass: - type: string - default: - description: Password for VPE Router - hostname: - type: string - default: - description: The hostname to set the vpe router to. diff --git a/vpe-router/layer.yaml b/vpe-router/layer.yaml deleted file mode 100644 index 524a4f45..00000000 --- a/vpe-router/layer.yaml +++ /dev/null @@ -1 +0,0 @@ -includes: ['layer:basic'] diff --git a/vpe-router/lib/charms/router.py b/vpe-router/lib/charms/router.py deleted file mode 100644 index 54ff7fb6..00000000 --- a/vpe-router/lib/charms/router.py +++ /dev/null @@ -1,80 +0,0 @@ - -import paramiko -import subprocess - -from charmhelpers.core.hookenv import config - - -class NetNS(object): - def __init__(self, name): - pass - - @classmethod - def create(cls, name): - # @TODO: Need to check if namespace exists already - try: - ip('netns', 'add', name) - except Exception as e: - raise Exception('could not create net namespace: %s' % e) - - return cls(name) - - def up(self, iface, cidr): - self.do('ip', 'link', 'set', 'dev', iface, 'up') - self.do('ip', 'address', 'add', cidr, 'dev', iface) - - def add_iface(self, iface): - ip('link', 'set', 'dev', iface, 'netns', self.name) - - def do(self, *cmd): - ip(*['netns', 'exec', self.name] + cmd) - - -def ip(*args): - return _run(['ip'] + list(args)) - - -def _run(cmd, env=None): - if isinstance(cmd, str): - cmd = cmd.split() if ' ' in cmd else [cmd] - - cfg = config() - if all(k in cfg for k in ['pass', 'vpe-router', 'user']): - router = cfg['vpe-router'] - user = cfg['user'] - passwd = cfg['pass'] - - if router and user and passwd: - return ssh(cmd, router, user, passwd) - - p = subprocess.Popen(cmd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - stdout, stderr = p.communicate() - retcode = p.poll() - if retcode > 0: - raise subprocess.CalledProcessError(returncode=retcode, - cmd=cmd, - output=stderr.decode("utf-8").strip()) - return (''.join(stdout), ''.join(stderr)) - - -def ssh(cmd, host, user, password=None): - ''' Suddenly this project needs to SSH to something. So we replicate what - _run was doing with subprocess using the Paramiko library. This is - temporary until this charm /is/ the VPE Router ''' - - cmds = ' '.join(cmd) - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - client.connect(host, port=22, username=user, password=password) - - stdin, stdout, stderr = client.exec_command(cmds) - retcode = stdout.channel.recv_exit_status() - client.close() # @TODO re-use connections - if retcode > 0: - output = stderr.read().strip() - raise subprocess.CalledProcessError(returncode=retcode, cmd=cmd, - output=output) - return (''.join(stdout), ''.join(stderr)) diff --git a/vpe-router/metadata.yaml b/vpe-router/metadata.yaml deleted file mode 100644 index 65ae2da3..00000000 --- a/vpe-router/metadata.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: vpe-router -maintainers: - - Marco Ceppi - - Adam Israel -summary: setup a virtualized PE Router with GRE tunnels -series: - - trusty - - xenial -description: | - this charm, when deployed and configured, will provide a secure virtualized - provider edge router. -peers: - loadbalance: - interface: vpe-router diff --git a/vpe-router/reactive/vpe_router.py b/vpe-router/reactive/vpe_router.py deleted file mode 100644 index e2be3276..00000000 --- a/vpe-router/reactive/vpe_router.py +++ /dev/null @@ -1,644 +0,0 @@ - -from charmhelpers.core.hookenv import ( - config, - status_set, - action_get, - action_fail, - log, -) - -from charms.reactive import ( - hook, - when, - when_not, - helpers, - set_state, - remove_state, -) - -from charms import router -import subprocess - -cfg = config() - - -@hook('config-changed') -def validate_config(): - try: - """ - If the ssh credentials are available, we'll act as a proxy charm. - Otherwise, we execute against the unit we're deployed on to. - """ - if all(k in cfg for k in ['pass', 'vpe-router', 'user']): - routerip = cfg['vpe-router'] - user = cfg['user'] - passwd = cfg['pass'] - - if routerip and user and passwd: - # Assumption: this will be a root user - out, err = router.ssh(['whoami'], routerip, - user, passwd) - if out.strip() != user: - remove_state('vpe.configured') - status_set('blocked', 'vpe is not configured') - raise Exception('invalid credentials') - - # Set the router's hostname - try: - if user == 'root' and 'hostname' in cfg: - hostname = cfg['hostname'] - out, err = router.ssh(['hostname', hostname], - routerip, - user, passwd) - out, err = router.ssh(['sed', - '-i', - '"s/hostname.*$/hostname %s/"' - % hostname, - '/usr/admin/global/hostname.sh' - ], - routerip, - user, passwd) - set_state('vpe.configured') - status_set('active', 'ready!') - else: - remove_state('vpe.configured') - status_set('blocked', 'vpe is not configured') - except subprocess.CalledProcessError as e: - remove_state('vpe.configured') - status_set('blocked', 'validation failed: %s' % e) - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - raise - - except Exception as e: - log(repr(e)) - remove_state('vpe.configured') - status_set('blocked', 'validation failed: %s' % e) - - -@when_not('vpe.configured') -def not_ready_add(): - actions = [ - 'vpe.add-corporation', - 'vpe.connect-domains', - 'vpe.delete-domain-connections', - 'vpe.remove-corporation', - 'vpe.configure-interface', - 'vpe.configure-ospf', - ] - - if helpers.any_states(*actions): - action_fail('VPE is not configured') - - status_set('blocked', 'vpe is not configured') - - -def start_ospfd(): - # We may want to make this configurable via config setting - ospfd = '/usr/local/bin/ospfd' - - try: - (stdout, stderr) = router._run(['touch', - '/usr/admin/global/ospfd.conf']) - (stdout, stderr) = router._run([ospfd, '-d', '-f', - '/usr/admin/global/ospfd.conf']) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - - -def configure_ospf(domain, cidr, area, subnet_cidr, subnet_area, enable=True): - """Configure the OSPF service""" - - # Check to see if the OSPF daemon is running, and start it if not - try: - (stdout, stderr) = router._run(['pgrep', 'ospfd']) - except subprocess.CalledProcessError as e: - # If pgrep fails, the process wasn't found. - start_ospfd() - log('Command failed (ospfd not running): %s (%s)' % - (' '.join(e.cmd), str(e.output))) - - upordown = '' - if not enable: - upordown = 'no' - try: - vrfctl = '/usr/local/bin/vrfctl' - vtysh = '/usr/local/bin/vtysh' - - (stdout, stderr) = router._run([vrfctl, 'list']) - - domain_id = 0 - for line in stdout.split('\n'): - if domain in line: - domain_id = int(line[3:5]) - - if domain_id > 0: - router._run([vtysh, - '-c', - '"configure terminal"', - '-c', - '"router ospf %d vr %d"' % (domain_id, domain_id), - '-c', - '"%s network %s area %s"' % (upordown, cidr, area), - '-c', - '"%s network %s area %s"' % (upordown, - subnet_cidr, - subnet_area), - ]) - - else: - log("Invalid domain id") - except subprocess.CalledProcessError as e: - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - finally: - remove_state('vpe.configure-interface') - status_set('active', 'ready!') - - -@when('vpe.configured') -@when('vpe.configure-interface') -def configure_interface(): - """ - Configure an ethernet interface - """ - iface_name = action_get('iface-name') - cidr = action_get('cidr') - - # cidr is optional - if cidr: - try: - # Add may fail, but change seems to add or update - router.ip('address', 'change', cidr, 'dev', iface_name) - except subprocess.CalledProcessError as e: - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - return - finally: - remove_state('vpe.configure-interface') - status_set('active', 'ready!') - - try: - router.ip('link', 'set', 'dev', iface_name, 'up') - except subprocess.CalledProcessError as e: - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - finally: - remove_state('vpe.configure-interface') - status_set('active', 'ready!') - - -@when('vpe.configured') -@when('vpe.add-corporation') -def add_corporation(): - ''' - Create and Activate the network corporation - ''' - domain_name = action_get('domain-name') - iface_name = action_get('iface-name') - # HACK: python's list, used deeper, throws an exception on ints in a tuple - vlan_id = str(action_get('vlan-id')) - cidr = action_get('cidr') - area = action_get('area') - subnet_cidr = action_get('subnet-cidr') - subnet_area = action_get('subnet-area') - - iface_vlanid = '%s.%s' % (iface_name, vlan_id) - - status_set('maintenance', 'adding corporation {}'.format(domain_name)) - - """ - Attempt to run all commands to add the network corporation. If any step - fails, abort and call `delete_corporation()` to undo. - """ - try: - """ - $ ip link add link eth3 name eth3.103 type vlan id 103 - """ - router.ip('link', - 'add', - 'link', - iface_name, - 'name', - iface_vlanid, - 'type', - 'vlan', - 'id', - vlan_id) - - """ - $ ip netns add domain - """ - router.ip('netns', - 'add', - domain_name) - - """ - $ ip link set dev eth3.103 netns corpB - """ - router.ip('link', - 'set', - 'dev', - iface_vlanid, - 'netns', - domain_name) - - """ - $ ifconfig eth3 up - """ - router._run(['ifconfig', iface_name, 'up']) - - """ - $ ip netns exec corpB ip link set dev eth3.103 up - """ - router.ip('netns', - 'exec', - domain_name, - 'ip', - 'link', - 'set', - 'dev', - iface_vlanid, - 'up') - - """ - $ ip netns exec corpB ip address add 10.0.1.1/24 dev eth3.103 - """ - mask = cidr.split("/")[1] - ip = '%s/%s' % (area, mask) - router.ip('netns', - 'exec', - domain_name, - 'ip', - 'address', - 'add', - ip, - 'dev', - iface_vlanid) - - configure_ospf(domain_name, cidr, area, subnet_cidr, subnet_area, True) - - except subprocess.CalledProcessError as e: - delete_corporation() - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - finally: - remove_state('vpe.add-corporation') - status_set('active', 'ready!') - - -@when('vpe.configured') -@when('vpe.delete-corporation') -def delete_corporation(): - - domain_name = action_get('domain-name') - cidr = action_get('cidr') - area = action_get('area') - subnet_cidr = action_get('subnet-cidr') - subnet_area = action_get('subnet-area') - - status_set('maintenance', 'deleting corporation {}'.format(domain_name)) - - try: - """ - Remove all tunnels defined for this domain - - $ ip netns exec domain_name ip tun show - | grep gre - | grep -v "remote any" - | cut -d":" -f1 - """ - p = router.ip( - 'netns', - 'exec', - domain_name, - 'ip', - 'tun', - 'show', - '|', - 'grep', - 'gre', - '|', - 'grep', - '-v', - '"remote any"', - '|', - 'cut -d":" -f1' - ) - - # `p` should be a tuple of (stdout, stderr) - tunnels = p[0].split('\n') - - for tunnel in tunnels: - try: - """ - $ ip netns exec domain_name ip link set $tunnel_name down - """ - router.ip( - 'netns', - 'exec', - domain_name, - 'ip', - 'link', - 'set', - tunnel, - 'down' - ) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - pass - - try: - """ - $ ip netns exec domain_name ip tunnel del $tunnel_name - """ - router.ip( - 'netns', - 'exec', - domain_name, - 'ip', - 'tunnel', - 'del', - tunnel - ) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - pass - - """ - Remove all interfaces associated to the domain - - $ ip netns exec domain_name ifconfig | grep mtu | cut -d":" -f1 - """ - p = router.ip( - 'netns', - 'exec', - domain_name, - 'ifconfig', - '|', - 'grep mtu', - '|', - 'cut -d":" -f1' - ) - - ifaces = p[0].split('\n') - for iface in ifaces: - - try: - """ - $ ip netns exec domain_name ip link set $iface down - """ - router.ip( - 'netns', - 'exec', - domain_name, - 'ip', - 'link', - 'set', - iface, - 'down' - ) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - - try: - """ - $ ifconfig eth3 down - """ - router._run(['ifconfig', iface, 'down']) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - pass - - try: - """ - $ ip link del dev $iface - """ - router.ip( - 'link', - 'del', - 'dev', - iface - ) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - pass - - try: - """ - Remove the domain - - $ ip netns del domain_name - """ - router.ip( - 'netns', - 'del', - domain_name - ) - except subprocess.CalledProcessError as e: - log('Command failed: %s (%s)' % (' '.join(e.cmd), str(e.output))) - pass - - try: - configure_ospf(domain_name, - cidr, - area, - subnet_cidr, - subnet_area, - False) - except subprocess.CalledProcessError as e: - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - - except: - # Do nothing - log('delete-corporation failed.') - pass - - finally: - remove_state('vpe.delete-corporation') - status_set('active', 'ready!') - - -@when('vpe.configured') -@when('vpe.connect-domains') -def connect_domains(): - - params = [ - 'domain-name', - 'iface-name', - 'tunnel-name', - 'local-ip', - 'remote-ip', - 'tunnel-key', - 'internal-local-ip', - 'internal-remote-ip', - 'tunnel-type', - ] - - config = {} - for p in params: - config[p] = action_get(p) - - status_set('maintenance', 'connecting domains') - - try: - """ - $ ip tunnel add tunnel_name mode gre local local_ip remote remote_ip - dev iface_name key tunnel_key csum - """ - router.ip( - 'tunnel', - 'add', - config['tunnel-name'], - 'mode', - config['tunnel-type'], - 'local', - config['local-ip'], - 'remote', - config['remote-ip'], - 'dev', - config['iface-name'], - 'key', - config['tunnel-key'], - 'csum' - ) - - except subprocess.CalledProcessError as e: - log('Command failed (retrying with ip tunnel change): %s (%s)' % - (' '.join(e.cmd), str(e.output))) - try: - """ - If the tunnel already exists (like gre0) and can't be deleted, - modify it instead of trying to add it. - """ - router.ip( - 'tunnel', - 'change', - config['tunnel-name'], - 'mode', - config['tunnel-type'], - 'local', - config['local-ip'], - 'remote', - config['remote-ip'], - 'dev', - config['iface-name'], - 'key', - config['tunnel-key'], - 'csum' - ) - except subprocess.CalledProcessError as e: - delete_domain_connection() - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - finally: - remove_state('vpe.connect-domains') - status_set('active', 'ready!') - - try: - """ - $ ip link set dev tunnel_name netns domain_name - """ - router.ip( - 'link', - 'set', - 'dev', - config['tunnel-name'], - 'netns', - config['domain-name'] - ) - - """ - $ ip netns exec domain_name ip link set dev tunnel_name up - """ - router.ip( - 'netns', - 'exec', - config['domain-name'], - 'ip', - 'link', - 'set', - 'dev', - config['tunnel-name'], - 'up' - ) - - """ - $ ip netns exec domain_name ip address add internal_local_ip peer - internal_remote_ip dev tunnel_name - """ - router.ip( - 'netns', - 'exec', - config['domain-name'], - 'ip', - 'address', - 'add', - config['internal-local-ip'], - 'peer', - config['internal-remote-ip'], - 'dev', - config['tunnel-name'] - ) - except subprocess.CalledProcessError as e: - delete_domain_connection() - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - finally: - remove_state('vpe.connect-domains') - status_set('active', 'ready!') - - -@when('vpe.configured') -@when('vpe.delete-domain-connection') -def delete_domain_connection(): - ''' Remove the tunnel to another router where the domain is present ''' - domain = action_get('domain-name') - tunnel_name = action_get('tunnel-name') - - status_set('maintenance', 'deleting domain connection: {}'.format(domain)) - - try: - - try: - """ - $ ip netns exec domain_name ip link set tunnel_name down - """ - router.ip('netns', - 'exec', - domain, - 'ip', - 'link', - 'set', - tunnel_name, - 'down') - except subprocess.CalledProcessError as e: - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - - try: - """ - $ ip netns exec domain_name ip tunnel del tunnel_name - """ - router.ip('netns', - 'exec', - domain, - 'ip', - 'tunnel', - 'del', - tunnel_name) - except subprocess.CalledProcessError as e: - action_fail('Command failed: %s (%s)' % - (' '.join(e.cmd), str(e.output))) - except: - pass - finally: - remove_state('vpe.delete-domain-connection') - status_set('active', 'ready!') diff --git a/vpe-router/wheelhouse.txt b/vpe-router/wheelhouse.txt deleted file mode 100644 index df2de692..00000000 --- a/vpe-router/wheelhouse.txt +++ /dev/null @@ -1 +0,0 @@ -paramiko>=1.16.0,<1.17