Port to MacOS

* Refactor Tunnel to support selecting port for remote sshd
* Refactor ssh tunnel to support MacOS version of ssh
* Refactor Tunnel.close() to find and kill off zombie siblings
* Add psutil dependency
* Add logging setup, letting library produce debugging records
* Clean up Tunnel API
* Fix test_runner.sh to propagate returncode to caller

Signed-off-by: Jhon Honce <jhonce@redhat.com>

Closes: #1199
Approved by: rhatdan
This commit is contained in:
Jhon Honce
2018-07-27 15:09:07 -07:00
committed by Atomic Bot
parent a1e3e542ff
commit 47620961fe
12 changed files with 190 additions and 80 deletions

View File

@ -1,5 +1,6 @@
"""A client for communicating with a Podman varlink service.""" """A client for communicating with a Podman varlink service."""
import errno import errno
import logging
import os import os
from urllib.parse import urlparse from urllib.parse import urlparse
@ -34,13 +35,17 @@ class BaseClient():
*args, *args,
**kwargs): **kwargs):
"""Construct a Client based on input.""" """Construct a Client based on input."""
log_level = os.environ.get('LOG_LEVEL')
if log_level is not None:
logging.basicConfig(level=logging.getLevelName(log_level.upper()))
if uri is None: if uri is None:
raise ValueError('uri is required and cannot be None') raise ValueError('uri is required and cannot be None')
if interface is None: if interface is None:
raise ValueError('interface is required and cannot be None') raise ValueError('interface is required and cannot be None')
unsupported = set(kwargs.keys()).difference( unsupported = set(kwargs.keys()).difference(
('uri', 'interface', 'remote_uri', 'identity_file')) ('uri', 'interface', 'remote_uri', 'port', 'identity_file'))
if unsupported: if unsupported:
raise ValueError('Unknown keyword arguments: {}'.format( raise ValueError('Unknown keyword arguments: {}'.format(
', '.join(unsupported))) ', '.join(unsupported)))
@ -50,38 +55,35 @@ class BaseClient():
raise ValueError('path is required for uri,' raise ValueError('path is required for uri,'
' expected format "unix://path_to_socket"') ' expected format "unix://path_to_socket"')
if kwargs.get('remote_uri'): if kwargs.get('remote_uri') is None:
# Remote access requires the full tuple of information return LocalClient(Context(uri, interface))
if kwargs.get('remote_uri') is None:
raise ValueError(
'remote_uri is required,'
' expected format "ssh://user@hostname/path_to_socket".')
remote = urlparse(kwargs['remote_uri'])
if remote.username is None:
raise ValueError(
'username is required for remote_uri,'
' expected format "ssh://user@hostname/path_to_socket".')
if remote.path == '':
raise ValueError(
'path is required for remote_uri,'
' expected format "ssh://user@hostname/path_to_socket".')
if remote.hostname is None:
raise ValueError(
'hostname is required for remote_uri,'
' expected format "ssh://user@hostname/path_to_socket".')
return RemoteClient( required = ('{} is required, expected format'
Context( ' "ssh://user@hostname[:port]/path_to_socket".')
uri,
interface, # Remote access requires the full tuple of information
local_path, if kwargs.get('remote_uri') is None:
remote.path, raise ValueError(required.format('remote_uri'))
remote.username,
remote.hostname, remote = urlparse(kwargs['remote_uri'])
kwargs.get('identity_file'), if remote.username is None:
)) raise ValueError(required.format('username'))
return LocalClient( if remote.path == '':
Context(uri, interface, None, None, None, None, None)) raise ValueError(required.format('path'))
if remote.hostname is None:
raise ValueError(required.format('hostname'))
return RemoteClient(
Context(
uri,
interface,
local_path,
remote.path,
remote.username,
remote.hostname,
remote.port,
kwargs.get('identity_file'),
))
class LocalClient(BaseClient): class LocalClient(BaseClient):
@ -115,7 +117,7 @@ class RemoteClient(BaseClient):
"""Context manager for API workers to access varlink.""" """Context manager for API workers to access varlink."""
tunnel = self._portal.get(self._context.uri) tunnel = self._portal.get(self._context.uri)
if tunnel is None: if tunnel is None:
tunnel = Tunnel(self._context).bore(self._context.uri) tunnel = Tunnel(self._context).bore()
self._portal[self._context.uri] = tunnel self._portal[self._context.uri] = tunnel
try: try:
@ -123,7 +125,7 @@ class RemoteClient(BaseClient):
self._iface = self._client.open(self._context.interface) self._iface = self._client.open(self._context.interface)
return self._iface return self._iface
except Exception: except Exception:
tunnel.close(self._context.uri) tunnel.close()
raise raise
def __exit__(self, e_type, e, e_traceback): def __exit__(self, e_type, e, e_traceback):
@ -133,6 +135,7 @@ class RemoteClient(BaseClient):
self._iface.close() self._iface.close()
# set timer to shutdown ssh tunnel # set timer to shutdown ssh tunnel
# self._portal.get(self._context.uri).close()
if isinstance(e, VarlinkError): if isinstance(e, VarlinkError):
raise error_factory(e) raise error_factory(e)

View File

@ -1,11 +1,15 @@
"""Cache for SSH tunnels.""" """Cache for SSH tunnels."""
import collections import collections
import getpass
import logging import logging
import os import os
import subprocess import subprocess
import threading import threading
import time import time
import weakref import weakref
from contextlib import suppress
import psutil
Context = collections.namedtuple('Context', ( Context = collections.namedtuple('Context', (
'uri', 'uri',
@ -14,8 +18,10 @@ Context = collections.namedtuple('Context', (
'remote_socket', 'remote_socket',
'username', 'username',
'hostname', 'hostname',
'port',
'identity_file', 'identity_file',
)) ))
Context.__new__.__defaults__ = (None, ) * len(Context._fields)
class Portal(collections.MutableMapping): class Portal(collections.MutableMapping):
@ -51,7 +57,7 @@ class Portal(collections.MutableMapping):
with self.lock: with self.lock:
value, _ = self.data[key] value, _ = self.data[key]
del self.data[key] del self.data[key]
value.close(key) value.close()
del value del value
def __iter__(self): def __iter__(self):
@ -93,47 +99,92 @@ class Tunnel():
def __init__(self, context): def __init__(self, context):
"""Construct Tunnel.""" """Construct Tunnel."""
self.context = context self.context = context
self._tunnel = None self._closed = True
def bore(self, ident): @property
def closed(self):
"""Is tunnel closed."""
return self._closed
def bore(self):
"""Create SSH tunnel from given context.""" """Create SSH tunnel from given context."""
cmd = ['ssh'] cmd = ['ssh', '-fNT']
ssh_opts = '-fNT'
if logging.getLogger().getEffectiveLevel() == logging.DEBUG: if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
ssh_opts += 'v' cmd.append('-v')
else: else:
ssh_opts += 'q' cmd.append('-q')
cmd.append(ssh_opts)
if self.context.port:
cmd.extend(('-p', str(self.context.port)))
cmd.extend(('-L', '{}:{}'.format(self.context.local_socket, cmd.extend(('-L', '{}:{}'.format(self.context.local_socket,
self.context.remote_socket))) self.context.remote_socket)))
if self.context.identity_file: if self.context.identity_file:
cmd.extend(('-i', self.context.identity_file)) cmd.extend(('-i', self.context.identity_file))
cmd.append('ssh://{}@{}'.format(self.context.username, cmd.append('{}@{}'.format(self.context.username,
self.context.hostname)) self.context.hostname))
logging.debug('Tunnel cmd "%s"', ' '.join(cmd)) logging.debug('Opening tunnel "%s", cmd "%s"', self.context.uri,
' '.join(cmd))
self._tunnel = subprocess.Popen(cmd, close_fds=True) tunnel = subprocess.Popen(cmd, close_fds=True)
# The return value of Popen() has no long term value as that process
# has already exited by the time control is returned here. This is a
# side effect of the -f option. wait() will be called to clean up
# resources.
for _ in range(300): for _ in range(300):
# TODO: Make timeout configurable # TODO: Make timeout configurable
if os.path.exists(self.context.local_socket): if os.path.exists(self.context.local_socket) \
or tunnel.returncode is not None:
break break
time.sleep(0.5) with suppress(subprocess.TimeoutExpired):
# waiting for either socket to be created
# or first child to exit
tunnel.wait(0.5)
else: else:
raise TimeoutError('Failed to create tunnel using: {}'.format( raise TimeoutError(
' '.join(cmd))) 'Failed to create tunnel "{}", using: "{}"'.format(
weakref.finalize(self, self.close, ident) self.context.uri, ' '.join(cmd)))
if tunnel.returncode is not None and tunnel.returncode != 0:
raise subprocess.CalledProcessError(tunnel.returncode,
' '.join(cmd))
tunnel.wait()
self._closed = False
weakref.finalize(self, self.close)
return self return self
def close(self, ident): def close(self):
"""Close SSH tunnel.""" """Close SSH tunnel."""
if self._tunnel is None: logging.debug('Closing tunnel "%s"', self.context.uri)
if self._closed:
return return
self._tunnel.kill() # Find all ssh instances for user with uri tunnel the hard way...
self._tunnel.wait(300) targets = [
os.remove(self.context.local_socket) p
self._tunnel = None for p in psutil.process_iter(attrs=['name', 'username', 'cmdline'])
if p.info['username'] == getpass.getuser()
and p.info['name'] == 'ssh'
and self.context.local_socket in ' '.join(p.info['cmdline'])
] # yapf: disable
# ask nicely for ssh to quit, reap results
for proc in targets:
proc.terminate()
_, alive = psutil.wait_procs(targets, timeout=300)
# kill off the uncooperative, then report any stragglers
for proc in alive:
proc.kill()
_, alive = psutil.wait_procs(targets, timeout=300)
for proc in alive:
logging.info('process %d survived SIGKILL, giving up.', proc.pid)
with suppress(OSError):
os.remove(self.context.local_socket)
self._closed = True

View File

@ -1,3 +1,4 @@
psutil
python-dateutil python-dateutil
setuptools>=39 setuptools>=39
varlink varlink

View File

@ -28,7 +28,7 @@ class TestClient(unittest.TestCase):
p = Client( p = Client(
uri='unix:/run/podman', uri='unix:/run/podman',
interface='io.projectatomic.podman', interface='io.projectatomic.podman',
remote_uri='ssh://user@hostname/run/podmain/podman', remote_uri='ssh://user@hostname/run/podman/podman',
identity_file='~/.ssh/id_rsa') identity_file='~/.ssh/id_rsa')
self.assertIsInstance(p._client, BaseClient) self.assertIsInstance(p._client, BaseClient)

View File

@ -19,9 +19,9 @@ function usage {
while getopts "vh" arg; do while getopts "vh" arg; do
case $arg in case $arg in
v ) VERBOSE='-v' ;; v ) VERBOSE='-v'; export LOG_LEVEL=debug ;;
h ) usage ; exit 0;; h ) usage ; exit 0 ;;
\? ) usage ; exit 2;; \? ) usage ; exit 2 ;;
esac esac
done done
shift $((OPTIND -1)) shift $((OPTIND -1))
@ -113,29 +113,36 @@ PODMAN_ARGS="--storage-driver=vfs \
--cni-config-dir=$CNI_CONFIG_PATH \ --cni-config-dir=$CNI_CONFIG_PATH \
" "
if [[ -n $VERBOSE ]]; then if [[ -n $VERBOSE ]]; then
PODMAN_ARGS="$PODMAN_ARGS --log-level=debug" PODMAN_ARGS="$PODMAN_ARGS --log-level=$LOG_LEVEL"
fi fi
PODMAN="podman $PODMAN_ARGS" PODMAN="podman $PODMAN_ARGS"
# document what we're about to do... # Run podman in background without systemd for test purposes
$PODMAN --version cat >/tmp/test_podman.output <<-EOT
$($PODMAN --version)
$PODMAN varlink --timeout=0 ${PODMAN_HOST}
==========================================
EOT
set -x set -x
# Run podman in background without systemd for test purposes $PODMAN varlink --timeout=0 ${PODMAN_HOST} >>/tmp/test_podman.output 2>&1 &
$PODMAN varlink --timeout=0 ${PODMAN_HOST} >/tmp/test_runner.output 2>&1 &
if [[ -z $1 ]]; then if [[ -z $1 ]]; then
export PYTHONPATH=. export PYTHONPATH=.
python3 -m unittest discover -s . $VERBOSE python3 -m unittest discover -s . $VERBOSE
RETURNCODE=$?
else else
export PYTHONPATH=.:./test export PYTHONPATH=.:./test
python3 -m unittest $1 $VERBOSE python3 -m unittest $1 $VERBOSE
RETURNCODE=$?
fi fi
set +x set +x
pkill -9 podman pkill -9 podman
pkill -9 conmon pkill -9 conmon
showlog /tmp/test_runner.output showlog /tmp/test_podman.output
showlog /tmp/alpine.log showlog /tmp/alpine.log
showlog /tmp/busybox.log showlog /tmp/busybox.log
exit $RETURNCODE

View File

@ -25,7 +25,7 @@ class TestSystem(unittest.TestCase):
def test_remote_ping(self): def test_remote_ping(self):
host = urlparse(self.host) host = urlparse(self.host)
remote_uri = 'ssh://root@localhost/{}'.format(host.path) remote_uri = 'ssh://root@localhost{}'.format(host.path)
local_uri = 'unix:{}/tunnel/podman.sock'.format(self.tmpdir) local_uri = 'unix:{}/tunnel/podman.sock'.format(self.tmpdir)
with podman.Client( with podman.Client(
@ -33,7 +33,7 @@ class TestSystem(unittest.TestCase):
remote_uri=remote_uri, remote_uri=remote_uri,
identity_file=os.path.expanduser('~/.ssh/id_rsa'), identity_file=os.path.expanduser('~/.ssh/id_rsa'),
) as remote_client: ) as remote_client:
remote_client.system.ping() self.assertTrue(remote_client.system.ping())
def test_versions(self): def test_versions(self):
with podman.Client(self.host) as pclient: with podman.Client(self.host) as pclient:

View File

@ -4,7 +4,6 @@ import time
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import podman
from podman.libs.tunnel import Context, Portal, Tunnel from podman.libs.tunnel import Context, Portal, Tunnel
@ -60,20 +59,22 @@ class TestTunnel(unittest.TestCase):
'/run/podman/socket', '/run/podman/socket',
'user', 'user',
'hostname', 'hostname',
None,
'~/.ssh/id_rsa', '~/.ssh/id_rsa',
) )
tunnel = Tunnel(context).bore('unix:/01') tunnel = Tunnel(context).bore()
cmd = [ cmd = [
'ssh', 'ssh',
'-fNTq', '-fNT',
'-q',
'-L', '-L',
'{}:{}'.format(context.local_socket, context.remote_socket), '{}:{}'.format(context.local_socket, context.remote_socket),
'-i', '-i',
context.identity_file, context.identity_file,
'ssh://{}@{}'.format(context.username, context.hostname), '{}@{}'.format(context.username, context.hostname),
] ]
mock_finalize.assert_called_once_with(tunnel, tunnel.close, 'unix:/01') mock_finalize.assert_called_once_with(tunnel, tunnel.close)
mock_exists.assert_called_once_with(context.local_socket) mock_exists.assert_called_once_with(context.local_socket)
mock_Popen.assert_called_once_with(cmd, close_fds=True) mock_Popen.assert_called_once_with(cmd, close_fds=True)

View File

@ -46,6 +46,11 @@ user.
Name of remote host. There is no default, if not given \f[C]pypodman\f[] Name of remote host. There is no default, if not given \f[C]pypodman\f[]
attempts to connect to \f[C]\-\-remote\-socket\-path\f[] on local host. attempts to connect to \f[C]\-\-remote\-socket\-path\f[] on local host.
.PP .PP
\f[B]\[en]port\f[]
.PP
The optional port for \f[C]ssh\f[] to connect tunnel to on remote host.
Default is None and will allow \f[C]ssh\f[] to follow it's default configuration.
.PP
\f[B]\[en]remote\-socket\-path\f[] \f[B]\[en]remote\-socket\-path\f[]
.PP .PP
Path on remote host for podman service's \f[C]AF_UNIX\f[] socket. The default is Path on remote host for podman service's \f[C]AF_UNIX\f[] socket. The default is

View File

@ -33,6 +33,28 @@ class HelpFormatter(argparse.RawDescriptionHelpFormatter):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class PortAction(argparse.Action):
"""Validate port number given is positive integer."""
def __call__(self, parser, namespace, values, option_string=None):
"""Validate input."""
if values > 0:
setattr(namespace, self.dest, values)
return
msg = 'port numbers must be a positive integer.'
raise argparse.ArgumentError(self, msg)
class PathAction(argparse.Action):
"""Expand user- and relative-paths."""
def __call__(self, parser, namespace, values, option_string=None):
"""Resolve full path value."""
setattr(namespace, self.dest,
os.path.abspath(os.path.expanduser(values)))
class PodmanArgumentParser(argparse.ArgumentParser): class PodmanArgumentParser(argparse.ArgumentParser):
"""Default remote podman configuration.""" """Default remote podman configuration."""
@ -64,10 +86,17 @@ class PodmanArgumentParser(argparse.ArgumentParser):
' (default: XDG_RUNTIME_DIR/pypodman')) ' (default: XDG_RUNTIME_DIR/pypodman'))
self.add_argument( self.add_argument(
'--user', '--user',
'-l',
default=getpass.getuser(), default=getpass.getuser(),
help='Authenicating user on remote host. (default: %(default)s)') help='Authenicating user on remote host. (default: %(default)s)')
self.add_argument( self.add_argument(
'--host', help='name of remote host. (default: None)') '--host', help='name of remote host. (default: None)')
self.add_argument(
'--port',
'-p',
type=int,
action=PortAction,
help='port for ssh tunnel to remote host. (default: 22)')
self.add_argument( self.add_argument(
'--remote-socket-path', '--remote-socket-path',
metavar='PATH', metavar='PATH',
@ -75,11 +104,14 @@ class PodmanArgumentParser(argparse.ArgumentParser):
' (default: /run/podman/io.projectatomic.podman)')) ' (default: /run/podman/io.projectatomic.podman)'))
self.add_argument( self.add_argument(
'--identity-file', '--identity-file',
'-i',
metavar='PATH', metavar='PATH',
action=PathAction,
help=('path to ssh identity file. (default: ~user/.ssh/id_dsa)')) help=('path to ssh identity file. (default: ~user/.ssh/id_dsa)'))
self.add_argument( self.add_argument(
'--config-home', '--config-home',
metavar='DIRECTORY', metavar='DIRECTORY',
action=PathAction,
help=('home of configuration "pypodman.conf".' help=('home of configuration "pypodman.conf".'
' (default: XDG_CONFIG_HOME/pypodman')) ' (default: XDG_CONFIG_HOME/pypodman'))
@ -125,8 +157,8 @@ class PodmanArgumentParser(argparse.ArgumentParser):
if dir_ is None: if dir_ is None:
continue continue
with suppress(OSError): with suppress(OSError):
with open(os.path.join(dir_, 'pypodman/pypodman.conf'), with open(os.path.join(dir_,
'r') as stream: 'pypodman/pypodman.conf')) as stream:
config.update(pytoml.load(stream)) config.update(pytoml.load(stream))
def reqattr(name, value): def reqattr(name, value):
@ -156,6 +188,7 @@ class PodmanArgumentParser(argparse.ArgumentParser):
'user', 'user',
getattr(args, 'user') getattr(args, 'user')
or os.environ.get('USER') or os.environ.get('USER')
or os.environ.get('LOGNAME')
or config['default'].get('user') or config['default'].get('user')
or getpass.getuser() or getpass.getuser()
) # yapf:disable ) # yapf:disable
@ -195,8 +228,13 @@ class PodmanArgumentParser(argparse.ArgumentParser):
args.local_socket_path = args.remote_socket_path args.local_socket_path = args.remote_socket_path
args.local_uri = "unix:{}".format(args.local_socket_path) args.local_uri = "unix:{}".format(args.local_socket_path)
args.remote_uri = "ssh://{}@{}{}".format(args.user, args.host,
args.remote_socket_path) components = ['ssh://', args.user, '@', args.host]
if args.port:
components.extend((':', str(args.port)))
components.append(args.remote_socket_path)
args.remote_uri = ''.join(components)
return args return args
def exit(self, status=0, message=None): def exit(self, status=0, message=None):

View File

@ -2,6 +2,7 @@
import logging import logging
import os import os
import sys import sys
from subprocess import CalledProcessError
from pypodman.lib import PodmanArgumentParser from pypodman.lib import PodmanArgumentParser
@ -49,14 +50,15 @@ def main():
try: try:
returncode = getattr(obj, args.method)() returncode = getattr(obj, args.method)()
except KeyboardInterrupt:
pass
except AttributeError as e: except AttributeError as e:
logging.critical(e, exc_info=want_tb()) logging.critical(e, exc_info=want_tb())
logging.warning('See subparser "%s" configuration.', logging.warning('See subparser "%s" configuration.',
args.subparser_name) args.subparser_name)
returncode = 3 returncode = 3
except KeyboardInterrupt:
pass
except ( except (
CalledProcessError,
ConnectionRefusedError, ConnectionRefusedError,
ConnectionResetError, ConnectionResetError,
TimeoutError, TimeoutError,

View File

@ -203,6 +203,7 @@ BuildRequires: python3-varlink
Requires: python3-setuptools Requires: python3-setuptools
Requires: python3-varlink Requires: python3-varlink
Requires: python3-dateutil Requires: python3-dateutil
Requires: python3-psutil
Provides: python3-%{name} = %{version}-%{release} Provides: python3-%{name} = %{version}-%{release}
Summary: Python 3 bindings and client for %{name} Summary: Python 3 bindings and client for %{name}

View File

@ -42,6 +42,7 @@ Requires: python3-humanize
Requires: python3-pytoml Requires: python3-pytoml
Requires: python3-setuptools Requires: python3-setuptools
Requires: python3-varlink Requires: python3-varlink
Requires: python3-psutil
Requires: podman Requires: podman
%if 0%{?fedora} %if 0%{?fedora}