├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── dockerpty ├── __init__.py ├── io.py ├── pty.py └── tty.py ├── features ├── environment.py ├── exec_interactive_stdin.feature ├── exec_interactive_terminal.feature ├── exec_non_interactive.feature ├── interactive_stdin.feature ├── interactive_terminal.feature ├── non_interactive.feature ├── steps │ └── step_definitions.py └── utils.py ├── requirements-dev.txt ├── requirements.txt ├── requirements3-dev.txt ├── setup.py ├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_io.py │ └── test_tty.py └── util.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | build/ 4 | dist/ 5 | .cache/ 6 | .tox/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # dockerpty: .travis.yml 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | sudo: required 18 | 19 | language: python 20 | 21 | # https://github.com/travis-ci/travis-ci/issues/5142#issuecomment-158563862 22 | 23 | matrix: 24 | include: 25 | - python: "2.7" 26 | env: REQUIREMENTS=requirements-dev.txt VENV=python2.7 27 | services: 28 | - docker 29 | - python: "pypy" 30 | env: REQUIREMENTS=requirements-dev.txt VENV=pypy 31 | services: 32 | - docker 33 | - python: "3.4" 34 | env: REQUIREMENTS=requirements3-dev.txt VENV=python3.4 35 | services: 36 | - docker 37 | 38 | install: pip install -r $REQUIREMENTS 39 | script: py.test -q tests && behave -c --no-capture -q 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker PTY 2 | 3 | Provides the functionality needed to operate the pseudo-tty (PTY) allocated to 4 | a docker container, using the Python client. 5 | 6 | [![Build Status](https://travis-ci.org/d11wtq/dockerpty.svg?branch=master)] 7 | (https://travis-ci.org/d11wtq/dockerpty) 8 | 9 | ## Installation 10 | 11 | Via pip: 12 | 13 | ``` 14 | pip install dockerpty 15 | ``` 16 | 17 | Dependencies: 18 | 19 | * docker-py>=0.3.2 20 | 21 | However, this library does not explicitly declare this dependency in PyPi for a 22 | number of reasons. It is assumed you have it installed. 23 | 24 | ## Usage 25 | 26 | The following example will run busybox in a docker container and place the user 27 | at the shell prompt via Python. 28 | 29 | This obviously only works when run in a terminal. 30 | 31 | ``` python 32 | import docker 33 | import dockerpty 34 | 35 | client = docker.Client() 36 | container = client.create_container( 37 | image='busybox:latest', 38 | stdin_open=True, 39 | tty=True, 40 | command='/bin/sh', 41 | ) 42 | 43 | dockerpty.start(client, container) 44 | ``` 45 | 46 | Keyword arguments passed to `start()` will be forwarded onto the client to 47 | start the container. 48 | 49 | When the dockerpty is started, control is yielded to the container's PTY until 50 | the container exits, or the container's PTY is closed. 51 | 52 | This is a safe operation and all resources are restored back to their original 53 | states. 54 | 55 | > **Note:** dockerpty does support attaching to non-tty containers to stream 56 | container output, though it is obviously not possible to 'control' the 57 | container if you do not allocate a pseudo-tty. 58 | 59 | If you press `C-p C-q`, the container's PTY will be closed, but the container 60 | will keep running. In other words, you will have detached from the container 61 | and can re-attach with another `dockerpty.start()` call. 62 | 63 | ## Tests 64 | 65 | If you want to hack on dockerpty and send a PR, you'll need to run the tests. 66 | In the features/ directory, are features/user stories for how dockerpty is 67 | supposed to work. To run them: 68 | 69 | ``` 70 | -bash$ pip install -r requirements-dev.txt 71 | -bash$ behave features/ 72 | ``` 73 | 74 | You'll need to have docker installed and running locally. The tests use busybox 75 | container as a test fixture, so are not too heavy. 76 | 77 | Step definitions are defined in features/steps/. 78 | 79 | There are also unit tests for the parts of the code that are not inherently 80 | dependent on controlling a TTY. To run those: 81 | 82 | ``` 83 | -bash$ pip install -r requirements-dev.txt 84 | -bash$ py.test tests/ 85 | ``` 86 | 87 | Travis CI runs this build inside a UML kernel that is new enough to run docker. 88 | Your PR will need to pass the build before I can merge it. 89 | 90 | - Travis CI build: https://travis-ci.org/d11wtq/dockerpty 91 | 92 | ## How it works 93 | 94 | In a terminal, the three file descriptors stdin, stdout and stderr are all 95 | connected to the controlling terminal (TTY). When you pass the `tty=True` flag 96 | to docker's `create_container()`, docker allocates a fake TTY inside the 97 | container (a PTY) to which the container's stdin, stdout and stderr are all 98 | connected. 99 | 100 | The docker API provides a way to access the three sockets connected to the PTY. 101 | If with access to the host system's TTY file descriptors and the container's 102 | PTY file descriptors, it is trivial to simply 'pipe' data written to these file 103 | descriptors between the host and the container. Doing this makes the user's 104 | terminal effectively become the pseudo-terminal from inside the container. 105 | 106 | In reality it's a bit more complicated than this, since care must be taken to 107 | put the host terminal into raw mode (where keys such as enter are not 108 | interpreted with any special meaning) and restore it on exit. Additionally, the 109 | container's stdout and stderr streams along with `sys.stdin` must be made 110 | non-blocking so that they can be used with `select()` without blocking the main 111 | process. These attributes are restored on exit. 112 | 113 | The size of a terminal cannot be controlled by sending data to stdin and can 114 | only be controlled by the terminal program itself. Since the pseudo-terminal is 115 | running inside a real terminal, it is import that the size of the PTY be kept 116 | the same as that of the presenting TTY. For this reason, docker provides an API 117 | call to resize the allocated PTY. A SIGWINCH handler is used to detect window 118 | size changes and resize the pseudo-terminal as needed. 119 | 120 | ## Contributors 121 | 122 | - Primary author: [Chris Corbyn](https://github.com/d11wtq) 123 | - Collaborator: [Daniel Nephin](https://github.com/dnephin) 124 | - Contributor: [Stephen Moore](https://github.com/delfick) 125 | - Contributor: [Ben Firshman](https://github.com/bfirsh) 126 | 127 | ## Copyright & Licensing 128 | 129 | Copyright © 2014 Chris Corbyn. See the LICENSE.txt file for details. 130 | -------------------------------------------------------------------------------- /dockerpty/__init__.py: -------------------------------------------------------------------------------- 1 | # dockerpty. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from dockerpty.pty import PseudoTerminal, RunOperation, ExecOperation, exec_create 18 | 19 | 20 | def start(client, container, interactive=True, stdout=None, stderr=None, stdin=None, logs=None): 21 | """ 22 | Present the PTY of the container inside the current process. 23 | 24 | This is just a wrapper for PseudoTerminal(client, container).start() 25 | """ 26 | 27 | operation = RunOperation(client, container, interactive=interactive, stdout=stdout, 28 | stderr=stderr, stdin=stdin, logs=logs) 29 | 30 | PseudoTerminal(client, operation).start() 31 | 32 | 33 | def exec_command( 34 | client, container, command, interactive=True, stdout=None, stderr=None, stdin=None): 35 | """ 36 | Run provided command via exec API in provided container. 37 | 38 | This is just a wrapper for PseudoTerminal(client, container).exec_command() 39 | """ 40 | exec_id = exec_create(client, container, command, interactive=interactive) 41 | 42 | operation = ExecOperation(client, exec_id, 43 | interactive=interactive, stdout=stdout, stderr=stderr, stdin=stdin) 44 | PseudoTerminal(client, operation).start() 45 | 46 | 47 | def start_exec(client, exec_id, interactive=True, stdout=None, stderr=None, stdin=None): 48 | operation = ExecOperation(client, exec_id, 49 | interactive=interactive, stdout=stdout, stderr=stderr, stdin=stdin) 50 | PseudoTerminal(client, operation).start() 51 | -------------------------------------------------------------------------------- /dockerpty/io.py: -------------------------------------------------------------------------------- 1 | # dockerpty: io.py 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import os 18 | import fcntl 19 | import errno 20 | import struct 21 | import select as builtin_select 22 | import six 23 | 24 | 25 | def set_blocking(fd, blocking=True): 26 | """ 27 | Set the given file-descriptor blocking or non-blocking. 28 | 29 | Returns the original blocking status. 30 | """ 31 | 32 | old_flag = fcntl.fcntl(fd, fcntl.F_GETFL) 33 | 34 | if blocking: 35 | new_flag = old_flag & ~ os.O_NONBLOCK 36 | else: 37 | new_flag = old_flag | os.O_NONBLOCK 38 | 39 | fcntl.fcntl(fd, fcntl.F_SETFL, new_flag) 40 | 41 | return not bool(old_flag & os.O_NONBLOCK) 42 | 43 | 44 | def select(read_streams, write_streams, timeout=0): 45 | """ 46 | Select the streams from `read_streams` that are ready for reading, and 47 | streams from `write_streams` ready for writing. 48 | 49 | Uses `select.select()` internally but only returns two lists of ready streams. 50 | """ 51 | 52 | exception_streams = [] 53 | 54 | try: 55 | return builtin_select.select( 56 | read_streams, 57 | write_streams, 58 | exception_streams, 59 | timeout, 60 | )[0:2] 61 | except builtin_select.error as e: 62 | # POSIX signals interrupt select() 63 | no = e.errno if six.PY3 else e[0] 64 | if no == errno.EINTR: 65 | return ([], []) 66 | else: 67 | raise e 68 | 69 | 70 | class Stream(object): 71 | """ 72 | Generic Stream class. 73 | 74 | This is a file-like abstraction on top of os.read() and os.write(), which 75 | add consistency to the reading of sockets and files alike. 76 | """ 77 | 78 | """ 79 | Recoverable IO/OS Errors. 80 | """ 81 | ERRNO_RECOVERABLE = [ 82 | errno.EINTR, 83 | errno.EDEADLK, 84 | errno.EWOULDBLOCK, 85 | ] 86 | 87 | def __init__(self, fd): 88 | """ 89 | Initialize the Stream for the file descriptor `fd`. 90 | 91 | The `fd` object must have a `fileno()` method. 92 | """ 93 | self.fd = fd 94 | self.buffer = b'' 95 | self.close_requested = False 96 | self.closed = False 97 | 98 | def fileno(self): 99 | """ 100 | Return the fileno() of the file descriptor. 101 | """ 102 | 103 | return self.fd.fileno() 104 | 105 | def set_blocking(self, value): 106 | if hasattr(self.fd, 'setblocking'): 107 | self.fd.setblocking(value) 108 | return True 109 | else: 110 | return set_blocking(self.fd, value) 111 | 112 | def read(self, n=4096): 113 | """ 114 | Return `n` bytes of data from the Stream, or None at end of stream. 115 | """ 116 | 117 | while True: 118 | try: 119 | if hasattr(self.fd, 'recv'): 120 | return self.fd.recv(n) 121 | return os.read(self.fd.fileno(), n) 122 | except EnvironmentError as e: 123 | if e.errno not in Stream.ERRNO_RECOVERABLE: 124 | raise e 125 | 126 | 127 | def write(self, data): 128 | """ 129 | Write `data` to the Stream. Not all data may be written right away. 130 | Use select to find when the stream is writeable, and call do_write() 131 | to flush the internal buffer. 132 | """ 133 | 134 | if not data: 135 | return None 136 | 137 | self.buffer += data 138 | self.do_write() 139 | 140 | return len(data) 141 | 142 | def do_write(self): 143 | """ 144 | Flushes as much pending data from the internal write buffer as possible. 145 | """ 146 | while True: 147 | try: 148 | written = 0 149 | 150 | if hasattr(self.fd, 'send'): 151 | written = self.fd.send(self.buffer) 152 | else: 153 | written = os.write(self.fd.fileno(), self.buffer) 154 | 155 | self.buffer = self.buffer[written:] 156 | 157 | # try to close after writes if a close was requested 158 | if self.close_requested and len(self.buffer) == 0: 159 | self.close() 160 | 161 | return written 162 | except EnvironmentError as e: 163 | if e.errno not in Stream.ERRNO_RECOVERABLE: 164 | raise e 165 | 166 | def needs_write(self): 167 | """ 168 | Returns True if the stream has data waiting to be written. 169 | """ 170 | return len(self.buffer) > 0 171 | 172 | def close(self): 173 | self.close_requested = True 174 | 175 | # We don't close the fd immediately, as there may still be data pending 176 | # to write. 177 | if not self.closed and len(self.buffer) == 0: 178 | self.closed = True 179 | if hasattr(self.fd, 'close'): 180 | self.fd.close() 181 | else: 182 | os.close(self.fd.fileno()) 183 | 184 | def __repr__(self): 185 | return "{cls}({fd})".format(cls=type(self).__name__, fd=self.fd) 186 | 187 | 188 | class Demuxer(object): 189 | """ 190 | Wraps a multiplexed Stream to read in data demultiplexed. 191 | 192 | Docker multiplexes streams together when there is no PTY attached, by 193 | sending an 8-byte header, followed by a chunk of data. 194 | 195 | The first 4 bytes of the header denote the stream from which the data came 196 | (i.e. 0x01 = stdout, 0x02 = stderr). Only the first byte of these initial 4 197 | bytes is used. 198 | 199 | The next 4 bytes indicate the length of the following chunk of data as an 200 | integer in big endian format. This much data must be consumed before the 201 | next 8-byte header is read. 202 | """ 203 | 204 | def __init__(self, stream): 205 | """ 206 | Initialize a new Demuxer reading from `stream`. 207 | """ 208 | 209 | self.stream = stream 210 | self.remain = 0 211 | 212 | def fileno(self): 213 | """ 214 | Returns the fileno() of the underlying Stream. 215 | 216 | This is useful for select() to work. 217 | """ 218 | 219 | return self.stream.fileno() 220 | 221 | def set_blocking(self, value): 222 | return self.stream.set_blocking(value) 223 | 224 | def read(self, n=4096): 225 | """ 226 | Read up to `n` bytes of data from the Stream, after demuxing. 227 | 228 | Less than `n` bytes of data may be returned depending on the available 229 | payload, but the number of bytes returned will never exceed `n`. 230 | 231 | Because demuxing involves scanning 8-byte headers, the actual amount of 232 | data read from the underlying stream may be greater than `n`. 233 | """ 234 | 235 | size = self._next_packet_size(n) 236 | 237 | if size <= 0: 238 | return 239 | else: 240 | data = six.binary_type() 241 | while len(data) < size: 242 | nxt = self.stream.read(size - len(data)) 243 | if not nxt: 244 | # the stream has closed, return what data we got 245 | return data 246 | data = data + nxt 247 | return data 248 | 249 | def write(self, data): 250 | """ 251 | Delegates the the underlying Stream. 252 | """ 253 | 254 | return self.stream.write(data) 255 | 256 | def needs_write(self): 257 | """ 258 | Delegates to underlying Stream. 259 | """ 260 | 261 | if hasattr(self.stream, 'needs_write'): 262 | return self.stream.needs_write() 263 | 264 | return False 265 | 266 | def do_write(self): 267 | """ 268 | Delegates to underlying Stream. 269 | """ 270 | 271 | if hasattr(self.stream, 'do_write'): 272 | return self.stream.do_write() 273 | 274 | return False 275 | 276 | def close(self): 277 | """ 278 | Delegates to underlying Stream. 279 | """ 280 | 281 | return self.stream.close() 282 | 283 | def _next_packet_size(self, n=0): 284 | size = 0 285 | 286 | if self.remain > 0: 287 | size = min(n, self.remain) 288 | self.remain -= size 289 | else: 290 | data = six.binary_type() 291 | while len(data) < 8: 292 | nxt = self.stream.read(8 - len(data)) 293 | if not nxt: 294 | # The stream has closed, there's nothing more to read 295 | return 0 296 | data = data + nxt 297 | 298 | if data is None: 299 | return 0 300 | if len(data) == 8: 301 | __, actual = struct.unpack('>BxxxL', data) 302 | size = min(n, actual) 303 | self.remain = actual - size 304 | 305 | return size 306 | 307 | def __repr__(self): 308 | return "{cls}({stream})".format(cls=type(self).__name__, 309 | stream=self.stream) 310 | 311 | 312 | class Pump(object): 313 | """ 314 | Stream pump class. 315 | 316 | A Pump wraps two Streams, reading from one and and writing its data into 317 | the other, much like a pipe but manually managed. 318 | 319 | This abstraction is used to facilitate piping data between the file 320 | descriptors associated with the tty and those associated with a container's 321 | allocated pty. 322 | 323 | Pumps are selectable based on the 'read' end of the pipe. 324 | """ 325 | 326 | def __init__(self, 327 | from_stream, 328 | to_stream, 329 | wait_for_output=True, 330 | propagate_close=True): 331 | """ 332 | Initialize a Pump with a Stream to read from and another to write to. 333 | 334 | `wait_for_output` is a flag that says that we need to wait for EOF 335 | on the from_stream in order to consider this pump as "done". 336 | """ 337 | 338 | self.from_stream = from_stream 339 | self.to_stream = to_stream 340 | self.eof = False 341 | self.wait_for_output = wait_for_output 342 | self.propagate_close = propagate_close 343 | 344 | def fileno(self): 345 | """ 346 | Returns the `fileno()` of the reader end of the Pump. 347 | 348 | This is useful to allow Pumps to function with `select()`. 349 | """ 350 | 351 | return self.from_stream.fileno() 352 | 353 | def set_blocking(self, value): 354 | return self.from_stream.set_blocking(value) 355 | 356 | def flush(self, n=4096): 357 | """ 358 | Flush `n` bytes of data from the reader Stream to the writer Stream. 359 | 360 | Returns the number of bytes that were actually flushed. A return value 361 | of zero is not an error. 362 | 363 | If EOF has been reached, `None` is returned. 364 | """ 365 | 366 | try: 367 | read = self.from_stream.read(n) 368 | 369 | if read is None or len(read) == 0: 370 | self.eof = True 371 | if self.propagate_close: 372 | self.to_stream.close() 373 | return None 374 | 375 | return self.to_stream.write(read) 376 | except OSError as e: 377 | if e.errno != errno.EPIPE: 378 | raise e 379 | 380 | def is_done(self): 381 | """ 382 | Returns True if the read stream is done (either it's returned EOF or 383 | the pump doesn't have wait_for_output set), and the write 384 | side does not have pending bytes to send. 385 | """ 386 | 387 | return (not self.wait_for_output or self.eof) and \ 388 | not (hasattr(self.to_stream, 'needs_write') and self.to_stream.needs_write()) 389 | 390 | def __repr__(self): 391 | return "{cls}(from={from_stream}, to={to_stream})".format( 392 | cls=type(self).__name__, 393 | from_stream=self.from_stream, 394 | to_stream=self.to_stream) 395 | -------------------------------------------------------------------------------- /dockerpty/pty.py: -------------------------------------------------------------------------------- 1 | # dockerpty: pty.py 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import sys 18 | import signal 19 | import warnings 20 | from ssl import SSLError 21 | 22 | import dockerpty.io as io 23 | import dockerpty.tty as tty 24 | 25 | 26 | class WINCHHandler(object): 27 | """ 28 | WINCH Signal handler to keep the PTY correctly sized. 29 | """ 30 | 31 | def __init__(self, pty): 32 | """ 33 | Initialize a new WINCH handler for the given PTY. 34 | 35 | Initializing a handler has no immediate side-effects. The `start()` 36 | method must be invoked for the signals to be trapped. 37 | """ 38 | 39 | self.pty = pty 40 | self.original_handler = None 41 | 42 | def __enter__(self): 43 | """ 44 | Invoked on entering a `with` block. 45 | """ 46 | 47 | self.start() 48 | return self 49 | 50 | def __exit__(self, *_): 51 | """ 52 | Invoked on exiting a `with` block. 53 | """ 54 | 55 | self.stop() 56 | 57 | def start(self): 58 | """ 59 | Start trapping WINCH signals and resizing the PTY. 60 | 61 | This method saves the previous WINCH handler so it can be restored on 62 | `stop()`. 63 | """ 64 | 65 | def handle(signum, frame): 66 | if signum == signal.SIGWINCH: 67 | self.pty.resize() 68 | 69 | self.original_handler = signal.signal(signal.SIGWINCH, handle) 70 | 71 | def stop(self): 72 | """ 73 | Stop trapping WINCH signals and restore the previous WINCH handler. 74 | """ 75 | 76 | if self.original_handler is not None: 77 | signal.signal(signal.SIGWINCH, self.original_handler) 78 | 79 | 80 | class Operation(object): 81 | 82 | def israw(self, **kwargs): 83 | """ 84 | are we dealing with a tty or not? 85 | """ 86 | raise NotImplementedError() 87 | 88 | def start(self, **kwargs): 89 | """ 90 | start execution 91 | """ 92 | raise NotImplementedError() 93 | 94 | def resize(self, height, width, **kwargs): 95 | """ 96 | if we have terminal, resize it 97 | """ 98 | raise NotImplementedError() 99 | 100 | def sockets(self): 101 | """Return sockets for streams.""" 102 | raise NotImplementedError() 103 | 104 | 105 | class RunOperation(Operation): 106 | """ 107 | class for handling `docker run`-like command 108 | """ 109 | 110 | def __init__(self, client, container, interactive=True, stdout=None, stderr=None, stdin=None, logs=None): 111 | """ 112 | Initialize the PTY using the docker.Client instance and container dict. 113 | """ 114 | 115 | if logs is None: 116 | 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) 117 | logs = 1 118 | 119 | self.client = client 120 | self.container = container 121 | self.raw = None 122 | self.interactive = interactive 123 | self.stdout = stdout or sys.stdout 124 | self.stderr = stderr or sys.stderr 125 | self.stdin = stdin or sys.stdin 126 | self.logs = logs 127 | 128 | def start(self, sockets=None, **kwargs): 129 | """ 130 | Present the PTY of the container inside the current process. 131 | 132 | This will take over the current process' TTY until the container's PTY 133 | is closed. 134 | """ 135 | 136 | pty_stdin, pty_stdout, pty_stderr = sockets or self.sockets() 137 | pumps = [] 138 | 139 | if pty_stdin and self.interactive: 140 | pumps.append(io.Pump(io.Stream(self.stdin), pty_stdin, wait_for_output=False)) 141 | 142 | if pty_stdout: 143 | pumps.append(io.Pump(pty_stdout, io.Stream(self.stdout), propagate_close=False)) 144 | 145 | if pty_stderr: 146 | pumps.append(io.Pump(pty_stderr, io.Stream(self.stderr), propagate_close=False)) 147 | 148 | if not self._container_info()['State']['Running']: 149 | self.client.start(self.container, **kwargs) 150 | 151 | return pumps 152 | 153 | def israw(self, **kwargs): 154 | """ 155 | Returns True if the PTY should operate in raw mode. 156 | 157 | If the container was not started with tty=True, this will return False. 158 | """ 159 | 160 | if self.raw is None: 161 | info = self._container_info() 162 | self.raw = self.stdout.isatty() and info['Config']['Tty'] 163 | 164 | return self.raw 165 | 166 | def sockets(self): 167 | """ 168 | Returns a tuple of sockets connected to the pty (stdin,stdout,stderr). 169 | 170 | If any of the sockets are not attached in the container, `None` is 171 | returned in the tuple. 172 | """ 173 | 174 | info = self._container_info() 175 | 176 | def attach_socket(key): 177 | if info['Config']['Attach{0}'.format(key.capitalize())]: 178 | socket = self.client.attach_socket( 179 | self.container, 180 | {key: 1, 'stream': 1, 'logs': self.logs}, 181 | ) 182 | stream = io.Stream(socket) 183 | 184 | if info['Config']['Tty']: 185 | return stream 186 | else: 187 | return io.Demuxer(stream) 188 | else: 189 | return None 190 | 191 | return map(attach_socket, ('stdin', 'stdout', 'stderr')) 192 | 193 | def resize(self, height, width, **kwargs): 194 | """ 195 | resize pty within container 196 | """ 197 | self.client.resize(self.container, height=height, width=width) 198 | 199 | def _container_info(self): 200 | """ 201 | Thin wrapper around client.inspect_container(). 202 | """ 203 | 204 | return self.client.inspect_container(self.container) 205 | 206 | 207 | def exec_create(client, container, command, interactive=True): 208 | exec_id = client.exec_create(container, command, tty=interactive, stdin=interactive) 209 | return exec_id 210 | 211 | 212 | class ExecOperation(Operation): 213 | """ 214 | class for handling `docker exec`-like command 215 | """ 216 | 217 | def __init__(self, client, exec_id, interactive=True, stdout=None, stderr=None, stdin=None): 218 | self.exec_id = exec_id 219 | self.client = client 220 | self.raw = None 221 | self.interactive = interactive 222 | self.stdout = stdout or sys.stdout 223 | self.stderr = stderr or sys.stderr 224 | self.stdin = stdin or sys.stdin 225 | self._info = None 226 | 227 | def start(self, sockets=None, **kwargs): 228 | """ 229 | start execution 230 | """ 231 | stream = sockets or self.sockets() 232 | pumps = [] 233 | 234 | if self.interactive: 235 | pumps.append(io.Pump(io.Stream(self.stdin), stream, wait_for_output=False)) 236 | 237 | pumps.append(io.Pump(stream, io.Stream(self.stdout), propagate_close=False)) 238 | # FIXME: since exec_start returns a single socket, how do we 239 | # distinguish between stdout and stderr? 240 | # pumps.append(io.Pump(stream, io.Stream(self.stderr), propagate_close=False)) 241 | 242 | return pumps 243 | 244 | def israw(self, **kwargs): 245 | """ 246 | Returns True if the PTY should operate in raw mode. 247 | 248 | If the exec was not started with tty=True, this will return False. 249 | """ 250 | 251 | if self.raw is None: 252 | self.raw = self.stdout.isatty() and self.is_process_tty() 253 | 254 | return self.raw 255 | 256 | def sockets(self): 257 | """ 258 | Return a single socket which is processing all I/O to exec 259 | """ 260 | socket = self.client.exec_start(self.exec_id, socket=True, tty=self.interactive) 261 | stream = io.Stream(socket) 262 | if self.is_process_tty(): 263 | return stream 264 | else: 265 | return io.Demuxer(stream) 266 | 267 | def resize(self, height, width, **kwargs): 268 | """ 269 | resize pty of an execed process 270 | """ 271 | self.client.exec_resize(self.exec_id, height=height, width=width) 272 | 273 | def is_process_tty(self): 274 | """ 275 | does execed process have allocated tty? 276 | """ 277 | return self._exec_info()["ProcessConfig"]["tty"] 278 | 279 | def _exec_info(self): 280 | """ 281 | Caching wrapper around client.exec_inspect 282 | """ 283 | if self._info is None: 284 | self._info = self.client.exec_inspect(self.exec_id) 285 | return self._info 286 | 287 | 288 | class PseudoTerminal(object): 289 | """ 290 | Wraps the pseudo-TTY (PTY) allocated to a docker container. 291 | 292 | The PTY is managed via the current process' TTY until it is closed. 293 | 294 | Example: 295 | 296 | import docker 297 | from dockerpty import PseudoTerminal 298 | 299 | client = docker.Client() 300 | container = client.create_container( 301 | image='busybox:latest', 302 | stdin_open=True, 303 | tty=True, 304 | command='/bin/sh', 305 | ) 306 | 307 | # hijacks the current tty until the pty is closed 308 | PseudoTerminal(client, container).start() 309 | 310 | Care is taken to ensure all file descriptors are restored on exit. For 311 | example, you can attach to a running container from within a Python REPL 312 | and when the container exits, the user will be returned to the Python REPL 313 | without adverse effects. 314 | """ 315 | 316 | def __init__(self, client, operation): 317 | """ 318 | Initialize the PTY using the docker.Client instance and container dict. 319 | """ 320 | 321 | self.client = client 322 | self.operation = operation 323 | 324 | def sockets(self): 325 | return self.operation.sockets() 326 | 327 | def start(self, sockets=None): 328 | pumps = self.operation.start(sockets=sockets) 329 | 330 | flags = [p.set_blocking(False) for p in pumps] 331 | 332 | try: 333 | with WINCHHandler(self): 334 | self._hijack_tty(pumps) 335 | finally: 336 | if flags: 337 | for (pump, flag) in zip(pumps, flags): 338 | io.set_blocking(pump, flag) 339 | 340 | def resize(self, size=None): 341 | """ 342 | Resize the container's PTY. 343 | 344 | If `size` is not None, it must be a tuple of (height,width), otherwise 345 | it will be determined by the size of the current TTY. 346 | """ 347 | 348 | if not self.operation.israw(): 349 | return 350 | 351 | size = size or tty.size(self.operation.stdout) 352 | 353 | if size is not None: 354 | rows, cols = size 355 | try: 356 | self.operation.resize(height=rows, width=cols) 357 | except IOError: # Container already exited 358 | pass 359 | 360 | def _hijack_tty(self, pumps): 361 | with tty.Terminal(self.operation.stdin, raw=self.operation.israw()): 362 | self.resize() 363 | while True: 364 | read_pumps = [p for p in pumps if not p.eof] 365 | write_streams = [p.to_stream for p in pumps if p.to_stream.needs_write()] 366 | 367 | read_ready, write_ready = io.select(read_pumps, write_streams, timeout=60) 368 | try: 369 | for write_stream in write_ready: 370 | write_stream.do_write() 371 | 372 | for pump in read_ready: 373 | pump.flush() 374 | 375 | if all([p.is_done() for p in pumps]): 376 | break 377 | 378 | except SSLError as e: 379 | if 'The operation did not complete' not in e.strerror: 380 | raise e 381 | -------------------------------------------------------------------------------- /dockerpty/tty.py: -------------------------------------------------------------------------------- 1 | # dockerpty: tty.py 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from __future__ import absolute_import 18 | 19 | import os 20 | import termios 21 | import tty 22 | import fcntl 23 | import struct 24 | 25 | 26 | def size(fd): 27 | """ 28 | Return a tuple (rows,cols) representing the size of the TTY `fd`. 29 | 30 | The provided file descriptor should be the stdout stream of the TTY. 31 | 32 | If the TTY size cannot be determined, returns None. 33 | """ 34 | 35 | if not os.isatty(fd.fileno()): 36 | return None 37 | 38 | try: 39 | dims = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, 'hhhh')) 40 | except: 41 | try: 42 | dims = (os.environ['LINES'], os.environ['COLUMNS']) 43 | except: 44 | return None 45 | 46 | return dims 47 | 48 | 49 | class Terminal(object): 50 | """ 51 | Terminal provides wrapper functionality to temporarily make the tty raw. 52 | 53 | This is useful when streaming data from a pseudo-terminal into the tty. 54 | 55 | Example: 56 | 57 | with Terminal(sys.stdin, raw=True): 58 | do_things_in_raw_mode() 59 | """ 60 | 61 | def __init__(self, fd, raw=True): 62 | """ 63 | Initialize a terminal for the tty with stdin attached to `fd`. 64 | 65 | Initializing the Terminal has no immediate side effects. The `start()` 66 | method must be invoked, or `with raw_terminal:` used before the 67 | terminal is affected. 68 | """ 69 | 70 | self.fd = fd 71 | self.raw = raw 72 | self.original_attributes = None 73 | 74 | 75 | def __enter__(self): 76 | """ 77 | Invoked when a `with` block is first entered. 78 | """ 79 | 80 | self.start() 81 | return self 82 | 83 | 84 | def __exit__(self, *_): 85 | """ 86 | Invoked when a `with` block is finished. 87 | """ 88 | 89 | self.stop() 90 | 91 | 92 | def israw(self): 93 | """ 94 | Returns True if the TTY should operate in raw mode. 95 | """ 96 | 97 | return self.raw 98 | 99 | 100 | def start(self): 101 | """ 102 | Saves the current terminal attributes and makes the tty raw. 103 | 104 | This method returns None immediately. 105 | """ 106 | 107 | if os.isatty(self.fd.fileno()) and self.israw(): 108 | self.original_attributes = termios.tcgetattr(self.fd) 109 | tty.setraw(self.fd) 110 | 111 | 112 | def stop(self): 113 | """ 114 | Restores the terminal attributes back to before setting raw mode. 115 | 116 | If the raw terminal was not started, does nothing. 117 | """ 118 | 119 | if self.original_attributes is not None: 120 | termios.tcsetattr( 121 | self.fd, 122 | termios.TCSADRAIN, 123 | self.original_attributes, 124 | ) 125 | 126 | def __repr__(self): 127 | return "{cls}({fd}, raw={raw})".format( 128 | cls=type(self).__name__, 129 | fd=self.fd, 130 | raw=self.raw) 131 | -------------------------------------------------------------------------------- /features/environment.py: -------------------------------------------------------------------------------- 1 | # dockerpty: environment.py 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from docker.errors import NotFound 18 | 19 | from utils import get_client 20 | 21 | 22 | IMAGE = "busybox:latest" 23 | 24 | 25 | def before_all(ctx): 26 | """ 27 | Pulls down busybox:latest before anything is tested. 28 | """ 29 | 30 | ctx.client = get_client() 31 | try: 32 | ctx.client.inspect_image(IMAGE) 33 | except NotFound: 34 | ctx.client.pull(IMAGE) 35 | 36 | 37 | def after_scenario(ctx, scenario): 38 | """ 39 | Cleans up docker containers used as test fixtures after test completes. 40 | """ 41 | 42 | if hasattr(ctx, 'container') and hasattr(ctx, 'client'): 43 | try: 44 | ctx.client.remove_container(ctx.container, force=True) 45 | except: 46 | pass 47 | -------------------------------------------------------------------------------- /features/exec_interactive_stdin.feature: -------------------------------------------------------------------------------- 1 | # dockerpty: exec_interactive_stdin.feature. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | Feature: Executing command in a running docker container 19 | As a user I want to be able to execute a command in a running docker. 20 | 21 | 22 | Scenario: Capturing output 23 | Given I am using a TTY 24 | And I run "cat" in a docker container with stdin open 25 | And I start the container 26 | When I exec "sh -c 'ls -1 / | tail -n 3'" in a running docker container 27 | Then I will see the output 28 | """ 29 | tmp 30 | usr 31 | var 32 | """ 33 | 34 | 35 | Scenario: Sending input 36 | Given I am using a TTY 37 | And I run "cat" in a docker container with stdin open 38 | And I start the container 39 | When I exec "/bin/cat" in a running docker container with a PTY 40 | And I type "Hello World!" 41 | And I press ENTER 42 | Then I will see the output 43 | """ 44 | Hello World! 45 | Hello World! 46 | """ 47 | 48 | 49 | Scenario: Capturing errors 50 | Given I am using a TTY 51 | And I run "cat" in a docker container with stdin open 52 | And I start the container 53 | When I exec "sh -c 'cat | sh'" in a running docker container with a PTY 54 | And I type "echo 'Hello World!' 1>&2" 55 | And I press ENTER 56 | Then I will see the output 57 | """ 58 | echo 'Hello World!' 1>&2 59 | Hello World! 60 | """ 61 | 62 | 63 | Scenario: Capturing mixed output and errors 64 | Given I am using a TTY 65 | And I run "cat" in a docker container with stdin open 66 | And I start the container 67 | When I exec "sh -c 'cat | sh'" in a running docker container with a PTY 68 | And I type "echo 'Hello World!'" 69 | And I press ENTER 70 | Then I will see the output 71 | """ 72 | echo 'Hello World!' 73 | Hello World! 74 | """ 75 | When I type "echo 'Hello Universe!' 1>&2" 76 | And I press ENTER 77 | Then I will see the output 78 | """ 79 | echo 'Hello Universe!' 1>&2 80 | Hello Universe! 81 | """ 82 | -------------------------------------------------------------------------------- /features/exec_interactive_terminal.feature: -------------------------------------------------------------------------------- 1 | # dockerpty: exec_interactive_terminal.feature. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | Feature: Attaching to an interactive terminal in a docker container 19 | As a user I want to be able to spawn a shell in a running docker 20 | container, attach to it and control the shell inside my own terminal. 21 | 22 | 23 | Scenario: Starting the PTY 24 | Given I am using a TTY 25 | And I run "cat" in a docker container with stdin open 26 | And I start the container 27 | And I exec "/bin/sh" in a docker container with a PTY 28 | When I start exec with a PTY 29 | Then I will see the output 30 | """ 31 | / # 32 | """ 33 | 34 | 35 | Scenario: Controlling input 36 | Given I am using a TTY 37 | And I run "cat" in a docker container with stdin open 38 | And I start the container 39 | And I exec "/bin/sh" in a docker container with a PTY 40 | When I start exec with a PTY 41 | And I type "whoami" 42 | Then I will see the output 43 | """ 44 | / # whoami 45 | """ 46 | 47 | 48 | Scenario: Controlling standard output 49 | Given I am using a TTY 50 | And I run "cat" in a docker container with stdin open 51 | And I start the container 52 | And I exec "/bin/sh" in a docker container with a PTY 53 | When I start exec with a PTY 54 | And I type "uname" 55 | And I press ENTER 56 | Then I will see the output 57 | """ 58 | / # uname 59 | Linux 60 | / # 61 | """ 62 | 63 | 64 | Scenario: Controlling standard error 65 | Given I am using a TTY 66 | And I run "cat" in a docker container with stdin open 67 | And I start the container 68 | And I exec "/bin/sh" in a docker container with a PTY 69 | When I start exec with a PTY 70 | And I type "ls blah" 71 | And I press ENTER 72 | Then I will see the output 73 | """ 74 | / # ls blah 75 | ls: blah: No such file or directory 76 | / # 77 | """ 78 | 79 | 80 | Scenario: Initializing the PTY with the correct size 81 | Given I am using a TTY with dimensions 20 x 70 82 | And I run "cat" in a docker container with stdin open 83 | And I start the container 84 | And I exec "/bin/sh" in a docker container with a PTY 85 | When I start exec with a PTY 86 | And I type "stty size" 87 | And I press ENTER 88 | Then I will see the output 89 | """ 90 | / # stty size 91 | 20 70 92 | / # 93 | """ 94 | 95 | 96 | Scenario: Resizing the PTY 97 | Given I am using a TTY with dimensions 20 x 70 98 | And I run "cat" in a docker container with stdin open 99 | And I start the container 100 | And I exec "/bin/sh" in a docker container with a PTY 101 | When I start exec with a PTY 102 | And I resize the terminal to 30 x 100 103 | And I type "stty size" 104 | And I press ENTER 105 | Then I will see the output 106 | """ 107 | / # stty size 108 | 30 100 109 | / # 110 | """ 111 | 112 | 113 | Scenario: Resizing the PTY frenetically 114 | Given I am using a TTY with dimensions 20 x 70 115 | And I run "cat" in a docker container with stdin open 116 | And I start the container 117 | And I exec "/bin/sh" in a docker container with a PTY 118 | When I start exec with a PTY 119 | And I resize the terminal to 30 x 100 120 | And I resize the terminal to 30 x 101 121 | And I resize the terminal to 30 x 98 122 | And I resize the terminal to 28 x 98 123 | And I resize the terminal to 28 x 105 124 | And I type "stty size" 125 | And I press ENTER 126 | Then I will see the output 127 | """ 128 | / # stty size 129 | 28 105 130 | / # 131 | """ 132 | 133 | 134 | Scenario: Terminating the PTY 135 | Given I am using a TTY 136 | And I run "cat" in a docker container with stdin open 137 | And I start the container 138 | And I exec "/bin/sh" in a docker container with a PTY 139 | When I start exec with a PTY 140 | And I type "exit" 141 | And I press ENTER 142 | Then The PTY will be closed cleanly 143 | 144 | 145 | Scenario: Detaching from the PTY 146 | Given I am using a TTY 147 | And I run "cat" in a docker container with stdin open 148 | And I start the container 149 | And I exec "/bin/sh" in a docker container with a PTY 150 | When I start exec with a PTY 151 | And I press ENTER 152 | And I press C-p 153 | And I press C-q 154 | Then The PTY will be closed cleanly 155 | 156 | 157 | Scenario: Cleanly exiting on race conditions 158 | Given I am using a TTY 159 | And I run "cat" in a docker container with stdin open 160 | And I start the container 161 | And I exec "/bin/true" in a docker container with a PTY 162 | When I start exec with a PTY 163 | Then The PTY will be closed cleanly 164 | 165 | 166 | Scenario: Running when the container is started 167 | Given I am using a TTY 168 | And I run "cat" in a docker container with stdin open 169 | And I start the container 170 | When I exec "/bin/sh" in a running docker container with a PTY 171 | And I press ENTER 172 | Then I will see the output 173 | """ 174 | / # 175 | """ 176 | -------------------------------------------------------------------------------- /features/exec_non_interactive.feature: -------------------------------------------------------------------------------- 1 | # dockerpty: exec_non_interactive.feature. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | Feature: Executing command in a running docker container non-interactively 19 | As a user I want to be able to execute a command in a running docker 20 | container and view its output in my own terminal. 21 | 22 | 23 | Scenario: Capturing output 24 | Given I am using a TTY 25 | And I run "cat" in a docker container with stdin open 26 | And I start the container 27 | When I exec "/bin/tail -f -n1 /etc/passwd" in a running docker container 28 | Then I will see the output 29 | """ 30 | nobody:x:99:99:nobody:/home:/bin/false 31 | """ 32 | 33 | 34 | Scenario: Capturing errors 35 | Given I am using a TTY 36 | And I run "cat" in a docker container with stdin open 37 | And I start the container 38 | When I exec "sh -c 'tail -f -n1 /etc/passwd 1>&2'" in a running docker container 39 | Then I will see the output 40 | """ 41 | nobody:x:99:99:nobody:/home:/bin/false 42 | """ 43 | 44 | 45 | Scenario: Ignoring input 46 | Given I am using a TTY 47 | And I run "cat" in a docker container with stdin open 48 | And I start the container 49 | When I exec "/bin/tail -f -n1 /etc/passwd" in a running docker container 50 | And I press ENTER 51 | Then I will see the output 52 | """ 53 | nobody:x:99:99:nobody:/home:/bin/false 54 | """ 55 | And The container will still be running 56 | -------------------------------------------------------------------------------- /features/interactive_stdin.feature: -------------------------------------------------------------------------------- 1 | # dockerpty: interactive_stdin.feature. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | Feature: Attaching to a docker container with stdin open 19 | As a user I want to be able to attach to a process in a running docker 20 | and send it data via stdin. 21 | 22 | 23 | Scenario: Capturing output 24 | Given I am using a TTY 25 | And I run "tail -n1 -f /etc/passwd" in a docker container with stdin open 26 | When I start dockerpty 27 | Then I will see the output 28 | """ 29 | nobody:x:99:99:nobody:/home:/bin/false 30 | """ 31 | 32 | 33 | Scenario: Sending input 34 | Given I am using a TTY 35 | And I run "/bin/cat" in a docker container with stdin open 36 | When I start dockerpty 37 | And I type "Hello World!" 38 | And I press ENTER 39 | Then I will see the output 40 | """ 41 | Hello World! 42 | Hello World! 43 | """ 44 | 45 | 46 | Scenario: Capturing errors 47 | Given I am using a TTY 48 | And I run "sh -c 'cat | sh'" in a docker container with stdin open 49 | When I start dockerpty 50 | And I type "echo 'Hello World!' 1>&2" 51 | And I press ENTER 52 | Then I will see the output 53 | """ 54 | echo 'Hello World!' 1>&2 55 | Hello World! 56 | """ 57 | 58 | 59 | Scenario: Capturing mixed output and errors 60 | Given I am using a TTY 61 | And I run "sh -c 'cat | sh'" in a docker container with stdin open 62 | When I start dockerpty 63 | And I type "echo 'Hello World!'" 64 | And I press ENTER 65 | Then I will see the output 66 | """ 67 | echo 'Hello World!' 68 | Hello World! 69 | """ 70 | When I type "echo 'Hello Universe!' 1>&2" 71 | And I press ENTER 72 | Then I will see the output 73 | """ 74 | echo 'Hello Universe!' 1>&2 75 | Hello Universe! 76 | """ 77 | 78 | 79 | Scenario: Closing input 80 | Given I am using a TTY 81 | And I run "/bin/cat" in a docker container with stdin open 82 | When I start dockerpty 83 | And I type "Hello World!" 84 | And I press ENTER 85 | Then I will see the output 86 | """ 87 | Hello World! 88 | Hello World! 89 | """ 90 | When I press C-d 91 | Then The PTY will be closed cleanly 92 | And The container will not be running 93 | 94 | 95 | Scenario: Running when the container is started 96 | Given I am using a TTY 97 | And I run "/bin/cat" in a docker container with stdin open 98 | When I start the container 99 | And I start dockerpty 100 | And I type "Hello World!" 101 | And I press ENTER 102 | Then I will see the output 103 | """ 104 | Hello World! 105 | Hello World! 106 | """ 107 | -------------------------------------------------------------------------------- /features/interactive_terminal.feature: -------------------------------------------------------------------------------- 1 | # dockerpty: interactive_terminal.feature. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | Feature: Attaching to an interactive terminal in a docker container 19 | As a user I want to be able to attach to a shell in a running docker 20 | container and control inside my own terminal. 21 | 22 | 23 | Scenario: Starting the PTY 24 | Given I am using a TTY 25 | And I run "/bin/sh" in a docker container with a PTY 26 | When I start dockerpty 27 | Then I will see the output 28 | """ 29 | / # 30 | """ 31 | 32 | 33 | Scenario: Starting the PTY againt container with disabled logging 34 | Given I am using a TTY 35 | And I run "/bin/sh" in a docker container with a PTY and disabled logging 36 | When I start dockerpty 37 | Then I will see the output 38 | """ 39 | / # 40 | """ 41 | 42 | 43 | Scenario: Controlling input 44 | Given I am using a TTY 45 | And I run "/bin/sh" in a docker container with a PTY 46 | When I start dockerpty 47 | And I type "whoami" 48 | Then I will see the output 49 | """ 50 | / # whoami 51 | """ 52 | 53 | 54 | Scenario: Controlling standard output 55 | Given I am using a TTY 56 | And I run "/bin/sh" in a docker container with a PTY 57 | When I start dockerpty 58 | And I type "uname" 59 | And I press ENTER 60 | Then I will see the output 61 | """ 62 | / # uname 63 | Linux 64 | / # 65 | """ 66 | 67 | 68 | Scenario: Controlling standard error 69 | Given I am using a TTY 70 | And I run "/bin/sh" in a docker container with a PTY 71 | When I start dockerpty 72 | And I type "ls blah" 73 | And I press ENTER 74 | Then I will see the output 75 | """ 76 | / # ls blah 77 | ls: blah: No such file or directory 78 | / # 79 | """ 80 | 81 | 82 | Scenario: Initializing the PTY with the correct size 83 | Given I am using a TTY with dimensions 20 x 70 84 | And I run "/bin/sh" in a docker container with a PTY 85 | When I start dockerpty 86 | And I type "stty size" 87 | And I press ENTER 88 | Then I will see the output 89 | """ 90 | / # stty size 91 | 20 70 92 | / # 93 | """ 94 | 95 | 96 | Scenario: Resizing the PTY 97 | Given I am using a TTY with dimensions 20 x 70 98 | And I run "/bin/sh" in a docker container with a PTY 99 | When I start dockerpty 100 | And I resize the terminal to 30 x 100 101 | And I type "stty size" 102 | And I press ENTER 103 | Then I will see the output 104 | """ 105 | / # stty size 106 | 30 100 107 | / # 108 | """ 109 | 110 | 111 | Scenario: Resizing the PTY frenetically 112 | Given I am using a TTY with dimensions 20 x 70 113 | And I run "/bin/sh" in a docker container with a PTY 114 | When I start dockerpty 115 | And I resize the terminal to 30 x 100 116 | And I resize the terminal to 30 x 101 117 | And I resize the terminal to 30 x 98 118 | And I resize the terminal to 28 x 98 119 | And I resize the terminal to 28 x 105 120 | And I type "stty size" 121 | And I press ENTER 122 | Then I will see the output 123 | """ 124 | / # stty size 125 | 28 105 126 | / # 127 | """ 128 | 129 | 130 | Scenario: Terminating the PTY 131 | Given I am using a TTY 132 | And I run "/bin/sh" in a docker container with a PTY 133 | When I start dockerpty 134 | And I type "exit" 135 | And I press ENTER 136 | Then The PTY will be closed cleanly 137 | And The container will not be running 138 | 139 | 140 | Scenario: Detaching from the PTY 141 | Given I am using a TTY 142 | And I run "/bin/sh" in a docker container with a PTY 143 | When I start dockerpty 144 | And I press ENTER 145 | And I press C-p 146 | And I press C-q 147 | Then The PTY will be closed cleanly 148 | And The container will still be running 149 | 150 | 151 | Scenario: Reattaching to the PTY 152 | Given I am using a TTY 153 | And I run "/bin/sh" in a docker container with a PTY 154 | When I start dockerpty 155 | And I type "uname" 156 | And I press ENTER 157 | And I press C-p 158 | And I press C-q 159 | Then The PTY will be closed cleanly 160 | When I start dockerpty 161 | And I press ENTER 162 | And I press UP 163 | And I press ENTER 164 | Then I will see the output 165 | """ 166 | / # uname 167 | Linux 168 | / # 169 | """ 170 | 171 | 172 | Scenario: Cleanly exiting on race conditions 173 | Given I am using a TTY 174 | And I run "/bin/true" in a docker container with a PTY 175 | When I start dockerpty 176 | Then The PTY will be closed cleanly 177 | 178 | 179 | Scenario: Running when the container is started 180 | Given I am using a TTY 181 | And I run "/bin/sh" in a docker container with a PTY 182 | When I start the container 183 | And I start dockerpty 184 | And I press ENTER 185 | Then I will see the output 186 | """ 187 | / # 188 | """ 189 | -------------------------------------------------------------------------------- /features/non_interactive.feature: -------------------------------------------------------------------------------- 1 | # dockerpty: non_interactive.feature. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | Feature: Attaching to a docker container non-interactively 19 | As a user I want to be able to attach to a process in a running docker 20 | container and view its output in my own terminal. 21 | 22 | 23 | Scenario: Capturing output 24 | Given I am using a TTY 25 | And I run "/bin/tail -f -n1 /etc/passwd" in a docker container 26 | When I start dockerpty 27 | Then I will see the output 28 | """ 29 | nobody:x:99:99:nobody:/home:/bin/false 30 | """ 31 | 32 | 33 | Scenario: Capturing errors 34 | Given I am using a TTY 35 | And I run "sh -c 'tail -f -n1 /etc/passwd 1>&2'" in a docker container 36 | When I start dockerpty 37 | Then I will see the output 38 | """ 39 | nobody:x:99:99:nobody:/home:/bin/false 40 | """ 41 | 42 | 43 | Scenario: Ignoring input 44 | Given I am using a TTY 45 | And I run "/bin/tail -n1 -f /etc/passwd" in a docker container 46 | When I start dockerpty 47 | And I press ENTER 48 | Then I will see the output 49 | """ 50 | nobody:x:99:99:nobody:/home:/bin/false 51 | """ 52 | And The container will still be running 53 | -------------------------------------------------------------------------------- /features/steps/step_definitions.py: -------------------------------------------------------------------------------- 1 | # dockerpty: step_definitions.py 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from behave import then, given, when 18 | from expects import expect, equal, be_true, be_false 19 | import tests.util as util 20 | 21 | import dockerpty 22 | import pty 23 | import sys 24 | import os 25 | import signal 26 | import time 27 | 28 | from utils import get_client 29 | 30 | 31 | def alloc_pty(ctx, f, *args, **kwargs): 32 | pid, fd = pty.fork() 33 | 34 | if pid == pty.CHILD: 35 | tty = os.ttyname(0) 36 | 37 | sys.stdin = open(tty, 'r') 38 | sys.stdout = open(tty, 'w') 39 | sys.stderr = open(tty, 'w') 40 | 41 | # alternative way of doing ^ is to do: 42 | # kwargs["stdout"] = open(tty, 'w') 43 | # kwargs["stderr"] = open(tty, 'w') 44 | # kwargs["stdin"] = open(tty, 'r') 45 | 46 | # Create a new client for the child process to avoid concurrency issues 47 | client = get_client() 48 | f(client, *args, **kwargs) 49 | sys.exit(0) 50 | else: 51 | ctx.pty = fd 52 | util.set_pty_size( 53 | ctx.pty, 54 | (ctx.rows, ctx.cols) 55 | ) 56 | ctx.pid = pid 57 | util.wait(ctx.pty, timeout=5) 58 | time.sleep(1) # give the terminal some time to print prompt 59 | 60 | # util.exit_code can be called only once 61 | ctx.exit_code = util.exit_code(ctx.pid, timeout=5) 62 | if ctx.exit_code != 0: 63 | raise Exception("child process did not finish correctly") 64 | 65 | 66 | @given('I am using a TTY') 67 | def step_impl(ctx): 68 | ctx.rows = 20 69 | ctx.cols = 80 70 | 71 | 72 | @given('I am using a TTY with dimensions {rows} x {cols}') 73 | def step_impl(ctx, rows, cols): 74 | ctx.rows = int(rows) 75 | ctx.cols = int(cols) 76 | 77 | 78 | @given('I run "{cmd}" in a docker container with a PTY') 79 | def step_impl(ctx, cmd): 80 | ctx.container = ctx.client.create_container( 81 | image='busybox:latest', 82 | command=cmd, 83 | stdin_open=True, 84 | tty=True, 85 | ) 86 | 87 | 88 | @given('I run "{cmd}" in a docker container with a PTY and disabled logging') 89 | def step_impl(ctx, cmd): 90 | ctx.container = ctx.client.create_container( 91 | image='busybox:latest', 92 | command=cmd, 93 | stdin_open=True, 94 | tty=True, 95 | host_config={"LogConfig": { 96 | "Type": "none" # there is not "none" driver on 1.8 97 | }} 98 | ) 99 | 100 | 101 | @given('I run "{cmd}" in a docker container') 102 | def step_impl(ctx, cmd): 103 | ctx.container = ctx.client.create_container( 104 | image='busybox:latest', 105 | command=cmd, 106 | ) 107 | 108 | 109 | @given('I exec "{cmd}" in a docker container with a PTY') 110 | def step_impl(ctx, cmd): 111 | ctx.exec_id = dockerpty.exec_create(ctx.client, ctx.container, cmd, interactive=True) 112 | 113 | 114 | @given('I run "{cmd}" in a docker container with stdin open') 115 | def step_impl(ctx, cmd): 116 | ctx.container = ctx.client.create_container( 117 | image='busybox:latest', 118 | command=cmd, 119 | stdin_open=True, 120 | ) 121 | 122 | 123 | @given('I start the container') 124 | def step_impl(ctx): 125 | ctx.client.start(ctx.container) 126 | 127 | 128 | @when('I start the container') 129 | def step_impl(ctx): 130 | ctx.client.start(ctx.container) 131 | 132 | 133 | @when('I start dockerpty') 134 | def step_impl(ctx): 135 | alloc_pty(ctx, dockerpty.start, ctx.container, logs=0) 136 | 137 | 138 | @when('I exec "{cmd}" in a running docker container') 139 | def step_impl(ctx, cmd): 140 | alloc_pty(ctx, dockerpty.exec_command, ctx.container, cmd, interactive=False) 141 | 142 | 143 | @when('I exec "{cmd}" in a running docker container with a PTY') 144 | def step_impl(ctx, cmd): 145 | alloc_pty(ctx, dockerpty.exec_command, ctx.container, cmd, interactive=True) 146 | 147 | 148 | @when('I start exec') 149 | def step_impl(ctx): 150 | alloc_pty(ctx, dockerpty.start_exec, ctx.exec_id, interactive=False) 151 | 152 | 153 | @when('I start exec with a PTY') 154 | def step_impl(ctx): 155 | alloc_pty(ctx, dockerpty.start_exec, ctx.exec_id, interactive=True) 156 | 157 | 158 | @when('I resize the terminal to {rows} x {cols}') 159 | def step_impl(ctx, rows, cols): 160 | ctx.rows = int(rows) 161 | ctx.cols = int(cols) 162 | util.set_pty_size( 163 | ctx.pty, 164 | (ctx.rows, ctx.cols) 165 | ) 166 | time.sleep(1) 167 | os.kill(ctx.pid, signal.SIGWINCH) 168 | 169 | 170 | @when('I type "{text}"') 171 | def step_impl(ctx, text): 172 | util.write(ctx.pty, text.encode()) 173 | 174 | 175 | @when('I press {key}') 176 | def step_impl(ctx, key): 177 | mappings = { 178 | "enter": b"\x0a", 179 | "up": b"\x1b[A", 180 | "down": b"\x1b[B", 181 | "right": b"\x1b[C", 182 | "left": b"\x1b[D", 183 | "esc": b"\x1b", 184 | "c-c": b"\x03", 185 | "c-d": b"\x04", 186 | "c-p": b"\x10", 187 | "c-q": b"\x11", 188 | } 189 | util.write(ctx.pty, mappings[key.lower()]) 190 | 191 | 192 | @then('I will see the output') 193 | def step_impl(ctx): 194 | # you should check `actual` when tests fail 195 | actual = util.read_printable(ctx.pty).splitlines() 196 | wanted = ctx.text.splitlines() 197 | expect(actual[-len(wanted):]).to(equal(wanted)) 198 | 199 | 200 | @then('The PTY will be closed cleanly') 201 | def step_impl(ctx): 202 | if not hasattr(ctx, "exit_code"): 203 | ctx.exit_code = util.exit_code(ctx.pid, timeout=5) 204 | expect(ctx.exit_code).to(equal(0)) 205 | 206 | 207 | @then('The container will not be running') 208 | def step_impl(ctx): 209 | running = util.container_running(ctx.client, ctx.container, duration=2) 210 | expect(running).to(be_false) 211 | 212 | 213 | @then('The container will still be running') 214 | def step_impl(ctx): 215 | running = util.container_running(ctx.client, ctx.container, duration=2) 216 | expect(running).to(be_true) 217 | -------------------------------------------------------------------------------- /features/utils.py: -------------------------------------------------------------------------------- 1 | import docker 2 | from docker.utils import kwargs_from_env 3 | 4 | 5 | def get_client(): 6 | kwargs = kwargs_from_env(assert_hostname=False) 7 | return docker.AutoVersionClient(**kwargs) 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | docker-py>=1.7.0rc2 2 | pytest>=2.5.2 3 | behave>=1.2.4 4 | expects>=0.4 5 | six>=1.3.0 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | six>=1.3.0 2 | -------------------------------------------------------------------------------- /requirements3-dev.txt: -------------------------------------------------------------------------------- 1 | docker-py>=1.7.0rc2 2 | pytest>=2.5.2 3 | behave>=1.2.4 4 | expects>=0.4 5 | six>=1.3.0 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # dockerpty. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from setuptools import setup 18 | import os 19 | 20 | 21 | def fopen(filename): 22 | return open(os.path.join(os.path.dirname(__file__), filename)) 23 | 24 | 25 | def read(filename): 26 | return fopen(filename).read() 27 | 28 | setup( 29 | name='dockerpty', 30 | version='0.4.1', 31 | description='Python library to use the pseudo-tty of a docker container', 32 | long_description=read('README.md'), 33 | url='https://github.com/d11wtq/dockerpty', 34 | author='Chris Corbyn', 35 | author_email='chris@w3style.co.uk', 36 | install_requires=['six >= 1.3.0'], 37 | license='Apache 2.0', 38 | keywords='docker, tty, pty, terminal', 39 | packages=['dockerpty'], 40 | classifiers=[ 41 | 'Development Status :: 4 - Beta', 42 | 'License :: OSI Approved :: Apache Software License', 43 | 'Programming Language :: Python', 44 | 'Environment :: Console', 45 | 'Intended Audience :: Developers', 46 | 'Topic :: Terminals', 47 | 'Topic :: Terminals :: Terminal Emulators/X Terminals', 48 | 'Topic :: Software Development :: Libraries', 49 | 'Topic :: Software Development :: Libraries :: Python Modules', 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d11wtq/dockerpty/f8d17d893c6758b7cc25825e99f6b02202632a97/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d11wtq/dockerpty/f8d17d893c6758b7cc25825e99f6b02202632a97/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_io.py: -------------------------------------------------------------------------------- 1 | # dockerpty: test_io.py. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from expects import expect, equal, be_none, be_true, be_false, raise_error 18 | from io import StringIO, BytesIO 19 | import dockerpty.io as io 20 | 21 | import sys 22 | import os 23 | import fcntl 24 | import socket 25 | import tempfile 26 | import six 27 | 28 | 29 | class WriteLimitedWrapper: 30 | def __init__(self, socket, limit): 31 | self.socket = socket 32 | self.limit = limit 33 | 34 | def send(self, string, *args): 35 | return self.socket.send(string[:self.limit], *args) 36 | 37 | def __getattr__(self, name): 38 | return getattr(self.socket, name) 39 | 40 | 41 | def is_fd_closed(fd): 42 | try: 43 | os.fstat(fd) 44 | return False 45 | except OSError: 46 | return True 47 | 48 | 49 | def test_set_blocking_changes_fd_flags(): 50 | with tempfile.TemporaryFile() as f: 51 | io.set_blocking(f, False) 52 | flags = fcntl.fcntl(f, fcntl.F_GETFL) 53 | expect(flags & os.O_NONBLOCK).to(equal(os.O_NONBLOCK)) 54 | 55 | io.set_blocking(f, True) 56 | flags = fcntl.fcntl(f, fcntl.F_GETFL) 57 | expect(flags & os.O_NONBLOCK).to(equal(0)) 58 | 59 | 60 | def test_set_blocking_returns_previous_state(): 61 | with tempfile.TemporaryFile() as f: 62 | io.set_blocking(f, True) 63 | expect(io.set_blocking(f, False)).to(be_true) 64 | 65 | io.set_blocking(f, False) 66 | expect(io.set_blocking(f, True)).to(be_false) 67 | 68 | 69 | def test_select_returns_streams_for_reading(): 70 | a, b = socket.socketpair() 71 | a.send(b'test') 72 | expect(io.select([a, b], [], timeout=0)).to(equal(([b], []))) 73 | b.send(b'test') 74 | expect(io.select([a, b], [], timeout=0)).to(equal(([a, b], []))) 75 | b.recv(4) 76 | expect(io.select([a, b], [], timeout=0)).to(equal(([a], []))) 77 | a.recv(4) 78 | expect(io.select([a, b], [], timeout=0)).to(equal(([], []))) 79 | 80 | def test_select_returns_streams_for_writing(): 81 | a, b = socket.socketpair() 82 | expect(io.select([a, b], [a, b], timeout=0)).to(equal(([], [a, b]))) 83 | 84 | 85 | class TestStream(object): 86 | 87 | def test_fileno_delegates_to_file_descriptor(self): 88 | stream = io.Stream(sys.stdout) 89 | expect(stream.fileno()).to(equal(sys.stdout.fileno())) 90 | 91 | def test_read_from_socket(self): 92 | a, b = socket.socketpair() 93 | a.send(b'test') 94 | stream = io.Stream(b) 95 | expect(stream.read(32)).to(equal(b'test')) 96 | 97 | def test_write_to_socket(self): 98 | a, b = socket.socketpair() 99 | stream = io.Stream(a) 100 | stream.write(b'test') 101 | expect(b.recv(32)).to(equal(b'test')) 102 | 103 | def test_read_from_file(self): 104 | with tempfile.TemporaryFile() as f: 105 | stream = io.Stream(f) 106 | f.write(b'test') 107 | f.seek(0) 108 | expect(stream.read(32)).to(equal(b'test')) 109 | 110 | def test_read_returns_empty_string_at_eof(self): 111 | with tempfile.TemporaryFile() as f: 112 | stream = io.Stream(f) 113 | expect(stream.read(32)).to(equal(b'')) 114 | 115 | def test_write_to_file(self): 116 | with tempfile.TemporaryFile() as f: 117 | stream = io.Stream(f) 118 | stream.write(b'test') 119 | f.seek(0) 120 | expect(f.read(32)).to(equal(b'test')) 121 | 122 | def test_write_returns_length_written(self): 123 | with tempfile.TemporaryFile() as f: 124 | stream = io.Stream(f) 125 | expect(stream.write(b'test')).to(equal(4)) 126 | 127 | def test_write_returns_none_when_no_data(self): 128 | stream = io.Stream(StringIO()) 129 | expect(stream.write('')).to(be_none) 130 | 131 | def test_repr(self): 132 | fd = StringIO() 133 | stream = io.Stream(fd) 134 | expect(repr(stream)).to(equal("Stream(%s)" % fd)) 135 | 136 | def test_partial_writes(self): 137 | a, b = socket.socketpair() 138 | a = WriteLimitedWrapper(a, 5) 139 | stream = io.Stream(a) 140 | written = stream.write(b'123456789') 141 | expect(written).to(equal(9)) 142 | read = b.recv(1024) 143 | expect(read).to(equal(b'12345')) 144 | 145 | expect(stream.needs_write()).to(be_true) 146 | stream.do_write() 147 | 148 | read = b.recv(1024) 149 | expect(read).to(equal(b'6789')) 150 | expect(stream.needs_write()).to(be_false) 151 | 152 | def test_close(self): 153 | a, b = socket.socketpair() 154 | stream = io.Stream(a) 155 | stream.close() 156 | expect(is_fd_closed(a.fileno())).to(be_true) 157 | 158 | def test_close_with_pending_data(self): 159 | a, b = socket.socketpair() 160 | a = WriteLimitedWrapper(a, 5) 161 | stream = io.Stream(a) 162 | stream.write(b'123456789') 163 | 164 | stream.close() 165 | expect(is_fd_closed(a.fileno())).to(be_false) 166 | 167 | stream.do_write() 168 | expect(is_fd_closed(a.fileno())).to(be_true) 169 | 170 | class SlowStream(object): 171 | def __init__(self, chunks): 172 | self.chunks = chunks 173 | 174 | def read(self, n=4096): 175 | if len(self.chunks) == 0: 176 | return '' 177 | else: 178 | if len(self.chunks[0]) <= n: 179 | chunk = self.chunks[0] 180 | self.chunks = self.chunks[1:] 181 | else: 182 | chunk = self.chunks[0][:n] 183 | self.chunks[0] = self.chunks[0][n:] 184 | return chunk 185 | 186 | 187 | class TestDemuxer(object): 188 | 189 | def create_fixture(self): 190 | chunks = [ 191 | b"\x01\x00\x00\x00\x00\x00\x00\x03foo", 192 | b"\x01\x00\x00\x00\x00\x00\x00\x01d", 193 | ] 194 | return six.BytesIO(six.binary_type().join(chunks)) 195 | 196 | def test_fileno_delegates_to_stream(self): 197 | demuxer = io.Demuxer(sys.stdout) 198 | expect(demuxer.fileno()).to(equal(sys.stdout.fileno())) 199 | 200 | def test_reading_single_chunk(self): 201 | demuxer = io.Demuxer(self.create_fixture()) 202 | expect(demuxer.read(32)).to(equal(b'foo')) 203 | 204 | def test_reading_multiple_chunks(self): 205 | demuxer = io.Demuxer(self.create_fixture()) 206 | expect(demuxer.read(32)).to(equal(b'foo')) 207 | expect(demuxer.read(32)).to(equal(b'd')) 208 | 209 | def test_reading_data_from_slow_stream(self): 210 | slow_stream = SlowStream([ 211 | b"\x01\x00\x00\x00\x00\x00\x00\x03f", 212 | b"oo", 213 | b"\x01\x00\x00\x00\x00\x00\x00\x01d", 214 | ]) 215 | 216 | demuxer = io.Demuxer(slow_stream) 217 | expect(demuxer.read(32)).to(equal(b'foo')) 218 | expect(demuxer.read(32)).to(equal(b'd')) 219 | 220 | def test_reading_size_from_slow_stream(self): 221 | slow_stream = SlowStream([ 222 | b'\x01\x00\x00\x00', 223 | b'\x00\x00\x00\x03foo', 224 | b'\x01\x00', 225 | b'\x00\x00\x00\x00\x00\x01d', 226 | ]) 227 | 228 | demuxer = io.Demuxer(slow_stream) 229 | expect(demuxer.read(32)).to(equal(b'foo')) 230 | expect(demuxer.read(32)).to(equal(b'd')) 231 | 232 | def test_reading_partial_chunk(self): 233 | demuxer = io.Demuxer(self.create_fixture()) 234 | expect(demuxer.read(2)).to(equal(b'fo')) 235 | 236 | def test_reading_overlapping_chunks(self): 237 | demuxer = io.Demuxer(self.create_fixture()) 238 | expect(demuxer.read(2)).to(equal(b'fo')) 239 | expect(demuxer.read(2)).to(equal(b'o')) 240 | expect(demuxer.read(2)).to(equal(b'd')) 241 | 242 | def test_write_delegates_to_stream(self): 243 | s = StringIO() 244 | demuxer = io.Demuxer(s) 245 | demuxer.write(u'test') 246 | expect(s.getvalue()).to(equal('test')) 247 | 248 | def test_repr(self): 249 | s = StringIO() 250 | demuxer = io.Demuxer(s) 251 | expect(repr(demuxer)).to(equal("Demuxer(%s)" % s)) 252 | 253 | 254 | class TestPump(object): 255 | 256 | def test_fileno_delegates_to_from_stream(self): 257 | pump = io.Pump(sys.stdout, sys.stderr) 258 | expect(pump.fileno()).to(equal(sys.stdout.fileno())) 259 | 260 | def test_flush_pipes_data_between_streams(self): 261 | a = StringIO(u'food') 262 | b = StringIO() 263 | pump = io.Pump(a, b) 264 | pump.flush(3) 265 | expect(a.read(1)).to(equal('d')) 266 | expect(b.getvalue()).to(equal('foo')) 267 | 268 | def test_flush_returns_length_written(self): 269 | a = StringIO(u'fo') 270 | b = StringIO() 271 | pump = io.Pump(a, b) 272 | expect(pump.flush(3)).to(equal(2)) 273 | 274 | def test_repr(self): 275 | a = StringIO(u'fo') 276 | b = StringIO() 277 | pump = io.Pump(a, b) 278 | expect(repr(pump)).to(equal("Pump(from=%s, to=%s)" % (a, b))) 279 | 280 | def test_is_done_when_pump_does_not_require_output_to_finish(self): 281 | a = StringIO() 282 | b = StringIO() 283 | pump = io.Pump(a, b, False) 284 | expect(pump.is_done()).to(be_true) 285 | 286 | def test_is_done_when_pump_does_require_output_to_finish(self): 287 | a = StringIO(u'123') 288 | b = StringIO() 289 | pump = io.Pump(a, b, True) 290 | expect(pump.is_done()).to(be_false) 291 | 292 | pump.flush() 293 | expect(pump.is_done()).to(be_false) 294 | 295 | pump.flush() 296 | expect(pump.is_done()).to(be_true) 297 | -------------------------------------------------------------------------------- /tests/unit/test_tty.py: -------------------------------------------------------------------------------- 1 | # dockerpty: test_tty.py. 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from expects import expect, equal, be_none, be_true, be_false 18 | import dockerpty.tty as tty 19 | import tests.util as util 20 | 21 | import os 22 | import pty 23 | import termios 24 | import tempfile 25 | 26 | 27 | def israw(fd): 28 | __, __, __, flags, __, __, __ = termios.tcgetattr(fd) 29 | return not flags & termios.ECHO 30 | 31 | 32 | def test_size_returns_none_for_non_tty(): 33 | with tempfile.TemporaryFile() as t: 34 | expect(tty.size(t)).to(be_none) 35 | 36 | 37 | def test_size_returns_a_tuple_for_a_tty(): 38 | fd, __ = pty.openpty() 39 | fd = os.fdopen(fd) 40 | util.set_pty_size(fd, (43, 120)) 41 | expect(tty.size(fd)).to(equal((43, 120))) 42 | 43 | 44 | class TestTerminal(object): 45 | 46 | def test_start_when_raw(self): 47 | fd, __ = pty.openpty() 48 | terminal = tty.Terminal(os.fdopen(fd), raw=True) 49 | expect(israw(fd)).to(be_false) 50 | terminal.start() 51 | expect(israw(fd)).to(be_true) 52 | 53 | def test_start_when_not_raw(self): 54 | fd, __ = pty.openpty() 55 | terminal = tty.Terminal(os.fdopen(fd), raw=False) 56 | expect(israw(fd)).to(be_false) 57 | terminal.start() 58 | expect(israw(fd)).to(be_false) 59 | 60 | def test_stop_when_raw(self): 61 | fd, __ = pty.openpty() 62 | terminal = tty.Terminal(os.fdopen(fd), raw=True) 63 | terminal.start() 64 | terminal.stop() 65 | expect(israw(fd)).to(be_false) 66 | 67 | def test_raw_with_block(self): 68 | fd, __ = pty.openpty() 69 | fd = os.fdopen(fd) 70 | 71 | with tty.Terminal(fd, raw=True): 72 | expect(israw(fd)).to(be_true) 73 | 74 | expect(israw(fd)).to(be_false) 75 | 76 | def test_start_does_not_crash_when_fd_is_not_a_tty(self): 77 | with tempfile.TemporaryFile() as f: 78 | terminal = tty.Terminal(f, raw=True) 79 | terminal.start() 80 | terminal.stop() 81 | 82 | def test_repr(self): 83 | fd = 'some_fd' 84 | terminal = tty.Terminal(fd, raw=True) 85 | expect(repr(terminal)).to(equal("Terminal(some_fd, raw=True)")) 86 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | # dockerpty: util.py 2 | # 3 | # Copyright 2014 Chris Corbyn 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import errno 18 | import termios 19 | import struct 20 | import fcntl 21 | import select 22 | import os 23 | import re 24 | import time 25 | import six 26 | 27 | 28 | def set_pty_size(fd, size): 29 | """ 30 | Resize the PTY at `fd` to (rows, cols) size. 31 | """ 32 | 33 | rows, cols = size 34 | fcntl.ioctl( 35 | fd, 36 | termios.TIOCSWINSZ, 37 | struct.pack('hhhh', rows, cols, 0, 0) 38 | ) 39 | 40 | 41 | def wait(fd, timeout=2): 42 | """ 43 | Wait until data is ready for reading on `fd`. 44 | """ 45 | 46 | return select.select([fd], [], [], timeout)[0] 47 | 48 | 49 | def printable(text): 50 | """ 51 | Convert text to only printable characters, as a user would see it. 52 | """ 53 | 54 | ansi = re.compile(r'\x1b\[[^Jm]*[Jm]') 55 | return ansi.sub('', text).rstrip() 56 | 57 | 58 | def write(fd, data): 59 | """ 60 | Write `data` to the PTY at `fd`. 61 | """ 62 | os.write(fd, data) 63 | 64 | 65 | def readchar(fd): 66 | """ 67 | Read a character from the PTY at `fd`, or nothing if no data to read. 68 | """ 69 | 70 | while True: 71 | ready = wait(fd) 72 | if len(ready) == 0: 73 | return six.binary_type() 74 | else: 75 | for s in ready: 76 | try: 77 | return os.read(s, 1) 78 | except OSError as ex: 79 | if ex.errno == errno.EIO: 80 | # exec ends with: 81 | # OSError: [Errno 5] Input/output error 82 | # no idea why 83 | return "" 84 | raise 85 | 86 | 87 | def readline(fd): 88 | """ 89 | Read a line from the PTY at `fd`, or nothing if no data to read. 90 | 91 | The line includes the line ending. 92 | """ 93 | 94 | output = six.binary_type() 95 | while True: 96 | char = readchar(fd) 97 | if char: 98 | output += char 99 | if char == b"\n": 100 | return output 101 | else: 102 | return output 103 | 104 | 105 | def read(fd): 106 | """ 107 | Read all output from the PTY at `fd`, or nothing if no data to read. 108 | """ 109 | 110 | output = six.binary_type() 111 | while True: 112 | line = readline(fd) 113 | if line: 114 | output = output + line 115 | else: 116 | return output.decode() 117 | 118 | 119 | def read_printable(fd): 120 | """ 121 | Read all output from the PTY at `fd` as a user would see it. 122 | 123 | Warning: This is not exhaustive; it won't render Vim, for example. 124 | """ 125 | 126 | lines = read(fd).splitlines() 127 | return "\n".join([printable(line) for line in lines]).lstrip("\r\n") 128 | 129 | 130 | def exit_code(pid, timeout=5): 131 | """ 132 | Wait up to `timeout` seconds for `pid` to exit and return its exit code. 133 | 134 | Returns -1 if the `pid` does not exit. 135 | """ 136 | 137 | start = time.time() 138 | while True: 139 | _, status = os.waitpid(pid, os.WNOHANG) 140 | if os.WIFEXITED(status): 141 | return os.WEXITSTATUS(status) 142 | else: 143 | if (time.time() - start) > timeout: 144 | return -1 145 | 146 | 147 | def container_running(client, container, duration=2): 148 | """ 149 | Predicate to check if a container continues to run after `duration` secs. 150 | """ 151 | 152 | time.sleep(duration) 153 | config = client.inspect_container(container) 154 | return config['State']['Running'] 155 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, pypy, py34 3 | 4 | [testenv] 5 | deps = 6 | py27: -rrequirements-dev.txt 7 | pypy: -rrequirements-dev.txt 8 | py34: -rrequirements3-dev.txt 9 | commands = 10 | py.test -v tests/ 11 | behave 12 | --------------------------------------------------------------------------------