381 lines
11 KiB
Python
381 lines
11 KiB
Python
# dockerpty: pty.py
|
|
#
|
|
# Copyright 2014 Chris Corbyn <chris@w3style.co.uk>
|
|
#
|
|
# 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.
|
|
|
|
import sys
|
|
import signal
|
|
import warnings
|
|
from ssl import SSLError
|
|
|
|
import dockerpty.io as io
|
|
import dockerpty.tty as tty
|
|
|
|
|
|
class WINCHHandler(object):
|
|
"""
|
|
WINCH Signal handler to keep the PTY correctly sized.
|
|
"""
|
|
|
|
def __init__(self, pty):
|
|
"""
|
|
Initialize a new WINCH handler for the given PTY.
|
|
|
|
Initializing a handler has no immediate side-effects. The `start()`
|
|
method must be invoked for the signals to be trapped.
|
|
"""
|
|
|
|
self.pty = pty
|
|
self.original_handler = None
|
|
|
|
def __enter__(self):
|
|
"""
|
|
Invoked on entering a `with` block.
|
|
"""
|
|
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
"""
|
|
Invoked on exiting a `with` block.
|
|
"""
|
|
|
|
self.stop()
|
|
|
|
def start(self):
|
|
"""
|
|
Start trapping WINCH signals and resizing the PTY.
|
|
|
|
This method saves the previous WINCH handler so it can be restored on
|
|
`stop()`.
|
|
"""
|
|
|
|
def handle(signum, frame):
|
|
if signum == signal.SIGWINCH:
|
|
self.pty.resize()
|
|
|
|
self.original_handler = signal.signal(signal.SIGWINCH, handle)
|
|
|
|
def stop(self):
|
|
"""
|
|
Stop trapping WINCH signals and restore the previous WINCH handler.
|
|
"""
|
|
|
|
if self.original_handler is not None:
|
|
signal.signal(signal.SIGWINCH, self.original_handler)
|
|
|
|
|
|
class Operation(object):
|
|
|
|
def israw(self, **kwargs):
|
|
"""
|
|
are we dealing with a tty or not?
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def start(self, **kwargs):
|
|
"""
|
|
start execution
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def resize(self, height, width, **kwargs):
|
|
"""
|
|
if we have terminal, resize it
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def sockets(self):
|
|
"""Return sockets for streams."""
|
|
raise NotImplementedError()
|
|
|
|
|
|
class RunOperation(Operation):
|
|
"""
|
|
class for handling `docker run`-like command
|
|
"""
|
|
|
|
def __init__(self, client, container, interactive=True, stdout=None, stderr=None, stdin=None, logs=None):
|
|
"""
|
|
Initialize the PTY using the docker.Client instance and container dict.
|
|
"""
|
|
|
|
if logs is None:
|
|
warnings.warn("The default behaviour of dockerpty is changing. Please add logs=1 to your dockerpty.start call to maintain existing behaviour. See https://github.com/d11wtq/dockerpty/issues/51 for details.", DeprecationWarning)
|
|
logs = 1
|
|
|
|
self.client = client
|
|
self.container = container
|
|
self.raw = None
|
|
self.interactive = interactive
|
|
self.stdout = stdout or sys.stdout
|
|
self.stderr = stderr or sys.stderr
|
|
self.stdin = stdin or sys.stdin
|
|
self.logs = logs
|
|
|
|
def start(self, sockets=None, **kwargs):
|
|
"""
|
|
Present the PTY of the container inside the current process.
|
|
|
|
This will take over the current process' TTY until the container's PTY
|
|
is closed.
|
|
"""
|
|
|
|
pty_stdin, pty_stdout, pty_stderr = sockets or self.sockets()
|
|
pumps = []
|
|
|
|
if pty_stdin and self.interactive:
|
|
pumps.append(io.Pump(io.Stream(self.stdin), pty_stdin, wait_for_output=False))
|
|
|
|
if pty_stdout:
|
|
pumps.append(io.Pump(pty_stdout, io.Stream(self.stdout), propagate_close=False))
|
|
|
|
if pty_stderr:
|
|
pumps.append(io.Pump(pty_stderr, io.Stream(self.stderr), propagate_close=False))
|
|
|
|
if not self._container_info()['State']['Running']:
|
|
self.client.start(self.container, **kwargs)
|
|
|
|
return pumps
|
|
|
|
def israw(self, **kwargs):
|
|
"""
|
|
Returns True if the PTY should operate in raw mode.
|
|
|
|
If the container was not started with tty=True, this will return False.
|
|
"""
|
|
|
|
if self.raw is None:
|
|
info = self._container_info()
|
|
self.raw = self.stdout.isatty() and info['Config']['Tty']
|
|
|
|
return self.raw
|
|
|
|
def sockets(self):
|
|
"""
|
|
Returns a tuple of sockets connected to the pty (stdin,stdout,stderr).
|
|
|
|
If any of the sockets are not attached in the container, `None` is
|
|
returned in the tuple.
|
|
"""
|
|
|
|
info = self._container_info()
|
|
|
|
def attach_socket(key):
|
|
if info['Config']['Attach{0}'.format(key.capitalize())]:
|
|
socket = self.client.attach_socket(
|
|
self.container,
|
|
{key: 1, 'stream': 1, 'logs': self.logs},
|
|
)
|
|
stream = io.Stream(socket)
|
|
|
|
if info['Config']['Tty']:
|
|
return stream
|
|
else:
|
|
return io.Demuxer(stream)
|
|
else:
|
|
return None
|
|
|
|
return map(attach_socket, ('stdin', 'stdout', 'stderr'))
|
|
|
|
def resize(self, height, width, **kwargs):
|
|
"""
|
|
resize pty within container
|
|
"""
|
|
self.client.resize(self.container, height=height, width=width)
|
|
|
|
def _container_info(self):
|
|
"""
|
|
Thin wrapper around client.inspect_container().
|
|
"""
|
|
|
|
return self.client.inspect_container(self.container)
|
|
|
|
|
|
def exec_create(client, container, command, interactive=True):
|
|
exec_id = client.exec_create(container, command, tty=interactive, stdin=interactive)
|
|
return exec_id
|
|
|
|
|
|
class ExecOperation(Operation):
|
|
"""
|
|
class for handling `docker exec`-like command
|
|
"""
|
|
|
|
def __init__(self, client, exec_id, interactive=True, stdout=None, stderr=None, stdin=None):
|
|
self.exec_id = exec_id
|
|
self.client = client
|
|
self.raw = None
|
|
self.interactive = interactive
|
|
self.stdout = stdout or sys.stdout
|
|
self.stderr = stderr or sys.stderr
|
|
self.stdin = stdin or sys.stdin
|
|
self._info = None
|
|
|
|
def start(self, sockets=None, **kwargs):
|
|
"""
|
|
start execution
|
|
"""
|
|
stream = sockets or self.sockets()
|
|
pumps = []
|
|
|
|
if self.interactive:
|
|
pumps.append(io.Pump(io.Stream(self.stdin), stream, wait_for_output=False))
|
|
|
|
pumps.append(io.Pump(stream, io.Stream(self.stdout), propagate_close=False))
|
|
# FIXME: since exec_start returns a single socket, how do we
|
|
# distinguish between stdout and stderr?
|
|
# pumps.append(io.Pump(stream, io.Stream(self.stderr), propagate_close=False))
|
|
|
|
return pumps
|
|
|
|
def israw(self, **kwargs):
|
|
"""
|
|
Returns True if the PTY should operate in raw mode.
|
|
|
|
If the exec was not started with tty=True, this will return False.
|
|
"""
|
|
|
|
if self.raw is None:
|
|
self.raw = self.stdout.isatty() and self.is_process_tty()
|
|
|
|
return self.raw
|
|
|
|
def sockets(self):
|
|
"""
|
|
Return a single socket which is processing all I/O to exec
|
|
"""
|
|
socket = self.client.exec_start(self.exec_id, socket=True, tty=self.interactive)
|
|
stream = io.Stream(socket)
|
|
if self.is_process_tty():
|
|
return stream
|
|
else:
|
|
return io.Demuxer(stream)
|
|
|
|
def resize(self, height, width, **kwargs):
|
|
"""
|
|
resize pty of an execed process
|
|
"""
|
|
self.client.exec_resize(self.exec_id, height=height, width=width)
|
|
|
|
def is_process_tty(self):
|
|
"""
|
|
does execed process have allocated tty?
|
|
"""
|
|
return self._exec_info()["ProcessConfig"]["tty"]
|
|
|
|
def _exec_info(self):
|
|
"""
|
|
Caching wrapper around client.exec_inspect
|
|
"""
|
|
if self._info is None:
|
|
self._info = self.client.exec_inspect(self.exec_id)
|
|
return self._info
|
|
|
|
|
|
class PseudoTerminal(object):
|
|
"""
|
|
Wraps the pseudo-TTY (PTY) allocated to a docker container.
|
|
|
|
The PTY is managed via the current process' TTY until it is closed.
|
|
|
|
Example:
|
|
|
|
import docker
|
|
from dockerpty import PseudoTerminal
|
|
|
|
client = docker.Client()
|
|
container = client.create_container(
|
|
image='busybox:latest',
|
|
stdin_open=True,
|
|
tty=True,
|
|
command='/bin/sh',
|
|
)
|
|
|
|
# hijacks the current tty until the pty is closed
|
|
PseudoTerminal(client, container).start()
|
|
|
|
Care is taken to ensure all file descriptors are restored on exit. For
|
|
example, you can attach to a running container from within a Python REPL
|
|
and when the container exits, the user will be returned to the Python REPL
|
|
without adverse effects.
|
|
"""
|
|
|
|
def __init__(self, client, operation):
|
|
"""
|
|
Initialize the PTY using the docker.Client instance and container dict.
|
|
"""
|
|
|
|
self.client = client
|
|
self.operation = operation
|
|
|
|
def sockets(self):
|
|
return self.operation.sockets()
|
|
|
|
def start(self, sockets=None):
|
|
pumps = self.operation.start(sockets=sockets)
|
|
|
|
flags = [p.set_blocking(False) for p in pumps]
|
|
|
|
try:
|
|
with WINCHHandler(self):
|
|
self._hijack_tty(pumps)
|
|
finally:
|
|
if flags:
|
|
for (pump, flag) in zip(pumps, flags):
|
|
io.set_blocking(pump, flag)
|
|
|
|
def resize(self, size=None):
|
|
"""
|
|
Resize the container's PTY.
|
|
|
|
If `size` is not None, it must be a tuple of (height,width), otherwise
|
|
it will be determined by the size of the current TTY.
|
|
"""
|
|
|
|
if not self.operation.israw():
|
|
return
|
|
|
|
size = size or tty.size(self.operation.stdout)
|
|
|
|
if size is not None:
|
|
rows, cols = size
|
|
try:
|
|
self.operation.resize(height=rows, width=cols)
|
|
except IOError: # Container already exited
|
|
pass
|
|
|
|
def _hijack_tty(self, pumps):
|
|
with tty.Terminal(self.operation.stdin, raw=self.operation.israw()):
|
|
self.resize()
|
|
while True:
|
|
read_pumps = [p for p in pumps if not p.eof]
|
|
write_streams = [p.to_stream for p in pumps if p.to_stream.needs_write()]
|
|
|
|
read_ready, write_ready = io.select(read_pumps, write_streams, timeout=60)
|
|
try:
|
|
for write_stream in write_ready:
|
|
write_stream.do_write()
|
|
|
|
for pump in read_ready:
|
|
pump.flush()
|
|
|
|
if all([p.is_done() for p in pumps]):
|
|
break
|
|
|
|
except SSLError as e:
|
|
if 'The operation did not complete' not in e.strerror:
|
|
raise e
|