diff --git a/requirements.txt b/requirements.txt index b7d8dd35..337735b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ PrettyTable>=0.7.1,<0.8 # BSD osc-lib>=1.2.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 oslo.utils>=3.16.0 # Apache-2.0 +websocket-client>=0.32.0 # LGPLv2+ diff --git a/zunclient/common/websocketclient/__init__.py b/zunclient/common/websocketclient/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/zunclient/common/websocketclient/exceptions.py b/zunclient/common/websocketclient/exceptions.py new file mode 100644 index 00000000..26d30ccc --- /dev/null +++ b/zunclient/common/websocketclient/exceptions.py @@ -0,0 +1,43 @@ +# Copyright 2014 +# The Cloudscaling Group, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy +# of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class ContainerWebSocketException(Exception): + 'base for all ContainerWebSocket interactive generated exceptions' + def __init__(self, wrapped=None, message=None): + self.wrapped = wrapped + if message: + self.message = message + if wrapped: + formatted_string = "%s:%s" % (self.message, str(self.wrapped)) + else: + formatted_string = "%s" % self.message + super(ContainerWebSocketException, self).__init__(formatted_string) + + +class UserExit(ContainerWebSocketException): + message = "User requested disconnect the container" + + +class Disconnected(ContainerWebSocketException): + message = "Remote host closed connection" + + +class ConnectionFailed(ContainerWebSocketException): + message = "Failed to connect to remote host" + + +class InvalidWebSocketLink(ContainerWebSocketException): + message = "Invalid websocket link when attach container" diff --git a/zunclient/common/websocketclient/websocketclient.py b/zunclient/common/websocketclient/websocketclient.py new file mode 100644 index 00000000..1cea63d3 --- /dev/null +++ b/zunclient/common/websocketclient/websocketclient.py @@ -0,0 +1,330 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2012 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import errno +import fcntl +import logging +import os +import select +import signal +import six +import socket +import struct +import sys +import termios +import time +import tty +import websocket + +from zunclient.common.websocketclient import exceptions +from zunclient.v1 import client + +LOG = logging.getLogger(__name__) + +DEFAULT_API_VERSION = '1' +DEFAULT_ENDPOINT_TYPE = 'publicURL' +DEFAULT_SERVICE_TYPE = 'container' + + +class WebSocketClient(object): + + def __init__(self, host_url, id, escape='~', + close_wait=0.5): + self.id = id + self.escape = escape + self.close_wait = close_wait + self.host_url = host_url + self.cs = None + + def init_httpclient(self): + """Initialize the httpclient + + Websocket client need to call httpclient to send the resize + command to Zun API server + """ + os_username = os.environ.get('OS_USERNAME') + os_password = os.environ.get('OS_PASSWORD') + os_project_name = os.environ.get('OS_PROJECT_NAME') + os_project_id = os.environ.get('OS_PROJECT_ID') + os_user_domain_id = os.environ.get('OS_USER_DOMAIN_ID') + os_user_domain_name = os.environ.get('OS_USER_DOMAIN_NAME') + os_project_domain_id = os.environ.get('OS_PROJECT_DOMAIN_ID') + os_project_domain_name = os.environ.get('OS_PROJECT_DOMAIN_NAME') + os_auth_url = os.environ.get('OS_AUTH_URL') + endpoint_type = os.environ.get('ENDPOINT_TYPE') + service_type = os.environ.get('SERVICE_TYPE') + os_region_name = os.environ.get('OS_REGION_NAME') + bypass_url = os.environ.get('BYPASS_URL') + insecure = os.environ.get('INSECURE') + if not endpoint_type: + endpoint_type = DEFAULT_ENDPOINT_TYPE + + if not service_type: + service_type = DEFAULT_SERVICE_TYPE + + self.cs = client.Client(username=os_username, + api_key=os_password, + project_id=os_project_id, + project_name=os_project_name, + user_domain_id=os_user_domain_id, + user_domain_name=os_user_domain_name, + project_domain_id=os_project_domain_id, + project_domain_name=os_project_domain_name, + auth_url=os_auth_url, + service_type=service_type, + region_name=os_region_name, + zun_url=bypass_url, + endpoint_type=endpoint_type, + insecure=insecure) + + def connect(self): + url = self.host_url + LOG.debug('connecting to: %s', url) + try: + self.ws = websocket.create_connection(url, + skip_utf8_validation=True) + print('connected to %s ,press Enter to continue' % self.id) + print('type %s. to disconnect' % self.escape) + except socket.error as e: + raise exceptions.ConnectionFailed(e) + except websocket.WebSocketConnectionClosedException as e: + raise exceptions.ConnectionFailed(e) + except websocket.WebSocketBadStatusException as e: + raise exceptions.ConnectionFailed(e) + + def start_loop(self): + self.poll = select.poll() + self.poll.register(sys.stdin, + select.POLLIN | select.POLLHUP | select.POLLPRI) + self.poll.register(self.ws, + select.POLLIN | select.POLLHUP | select.POLLPRI) + + self.start_of_line = False + self.read_escape = False + with WINCHHandler(self): + try: + self.setup_tty() + self.run_forever() + except socket.error as e: + raise exceptions.ConnectionFailed(e) + except websocket.WebSocketConnectionClosedException as e: + raise exceptions.Disconnected(e) + finally: + self.restore_tty() + + def run_forever(self): + LOG.debug('starting main loop in client') + self.quit = False + quitting = False + when = None + + while True: + try: + for fd, event in self.poll.poll(500): + if fd == self.ws.fileno(): + self.handle_websocket(event) + elif fd == sys.stdin.fileno(): + self.handle_stdin(event) + except select.error as e: + # POSIX signals interrupt select() + no = e.errno if six.PY3 else e[0] + if no == errno.EINTR: + continue + else: + raise e + + if self.quit and not quitting: + self.log.debug('entering close_wait') + quitting = True + when = time.time() + self.close_wait + + if quitting and time.time() > when: + self.log.debug('quitting') + break + + def setup_tty(self): + if os.isatty(sys.stdin.fileno()): + LOG.debug('putting tty into raw mode') + self.old_settings = termios.tcgetattr(sys.stdin) + tty.setraw(sys.stdin) + + def restore_tty(self): + if os.isatty(sys.stdin.fileno()): + LOG.debug('restoring tty configuration') + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, + self.old_settings) + + def handle_stdin(self, event): + if event in (select.POLLHUP, select.POLLNVAL): + LOG.debug('event %d on stdin', event) + + LOG.debug('eof on stdin') + self.poll.unregister(sys.stdin) + self.quit = True + + data = os.read(sys.stdin.fileno(), 1024) + LOG.debug('read %s (%d bytes) from stdin', repr(data), len(data)) + + if not data: + return + + if self.start_of_line and data == self.escape: + self.read_escape = True + return + + if self.read_escape and data == '.': + LOG.debug('exit by local escape code') + raise exceptions.UserExit() + elif self.read_escape: + self.read_escape = False + self.ws.send(self.escape) + + self.ws.send(data) + + if data == '\r': + self.start_of_line = True + else: + self.start_of_line = False + + def handle_websocket(self, event): + if event in (select.POLLHUP, select.POLLNVAL): + LOG.debug('event %d on websocket', event) + + LOG.debug('eof on websocket') + self.poll.unregister(self.ws) + self.quit = True + + data = self.ws.recv() + LOG.debug('read %s (%d bytes) from websocket from container', + repr(data), len(data)) + if not data: + return + + sys.stdout.write(data) + sys.stdout.flush() + + def handle_resize(self): + """send the POST to resize the tty session size in container. + + Resize the container's PTY. + If `size` is not None, it must be a tuple of (height,width), otherwise + it will be determined by the size of the current TTY. + """ + size = self.tty_size(sys.stdout) + + if size is not None: + rows, cols = size + try: + self.tty_resize(height=rows, width=cols) + except IOError: # Container already exited + pass + + def tty_size(self, fd): + """Get the tty size + + Return a tuple (rows,cols) representing the size of the TTY `fd`. + + The provided file descriptor should be the stdout stream of the TTY. + + If the TTY size cannot be determined, returns None. + """ + + if not os.isatty(fd.fileno()): + return None + + try: + dims = struct.unpack('hh', fcntl.ioctl(fd, + termios.TIOCGWINSZ, + 'hhhh')) + except Exception: + try: + dims = (os.environ['LINES'], os.environ['COLUMNS']) + except Exception: + return None + + return dims + + def tty_resize(self, height, width): + """Resize the tty session + + Get the client and send the tty size data to zun api server + The environment variables need to get when implement sending + operation. + """ + height = str(height) + width = str(width) + + self.cs.containers.resize(self.id, width, height) + + +class WINCHHandler(object): + """WINCH Signal handler + + WINCH Signal handler to keep the PTY correctly sized. + """ + + def __init__(self, client): + """Initialize a new WINCH handler for the given PTY. + + Initializing a handler has no immediate side-effects. The `start()` + method must be invoked for the signals to be trapped. + """ + + self.client = client + self.original_handler = None + + def __enter__(self): + """Enter + + Invoked on entering a `with` block. + """ + + self.start() + return self + + def __exit__(self, *_): + """Exit + + Invoked on exiting a `with` block. + """ + + self.stop() + + def start(self): + """Start + + Start trapping WINCH signals and resizing the PTY. + This method saves the previous WINCH handler so it can be restored on + `stop()`. + """ + + def handle(signum, frame): + if signum == signal.SIGWINCH: + LOG.debug("Send command to resize the tty session") + self.client.handle_resize() + + self.original_handler = signal.signal(signal.SIGWINCH, handle) + + def stop(self): + """stop + + Stop trapping WINCH signals and restore the previous WINCH handler. + """ + + if self.original_handler is not None: + signal.signal(signal.SIGWINCH, self.original_handler) diff --git a/zunclient/tests/test_websocketclient.py b/zunclient/tests/test_websocketclient.py new file mode 100644 index 00000000..2b149b36 --- /dev/null +++ b/zunclient/tests/test_websocketclient.py @@ -0,0 +1,51 @@ +# Copyright 2015 OpenStack LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +import testtools + +from zunclient.common.websocketclient import websocketclient + +CONTAINER_ID = "0f96db5a-26dc-4550-b1a8-b110bd9247cb" +ESCAPE_FLAG = "~" +URL = "ws://localhost:2375/v1.17/containers/201e4e22c5b2/" \ + "attach/ws?logs=0&stream=1&stdin=1&stdout=1&stderr=1" +URL1 = "ws://10.10.10.10:2375/v1.17/containers/***********/" \ + "attach/ws?logs=0&stream=1&stdin=1&stdout=1&stderr=1" +WAIT_TIME = 0.5 + + +class WebSocketClientTest(testtools.TestCase): + + def test_websocketclient_variables(self): + wsclient = websocketclient.WebSocketClient(host_url=URL, + id=CONTAINER_ID, + escape=ESCAPE_FLAG, + close_wait=WAIT_TIME) + self.assertEqual(wsclient.host_url, URL) + self.assertEqual(wsclient.id, CONTAINER_ID) + self.assertEqual(wsclient.escape, ESCAPE_FLAG) + self.assertEqual(wsclient.close_wait, WAIT_TIME) + + @mock.patch('zunclient.v1.client.Client') + def test_init_httpclient(self, mock_client): + wsclient = websocketclient.WebSocketClient(host_url=URL, + id=CONTAINER_ID, + escape=ESCAPE_FLAG, + close_wait=WAIT_TIME) + mock_client.return_value = 'Client Object' + wsclient.init_httpclient() + self.assertEqual(wsclient.cs, 'Client Object') + self.assertTrue(mock_client.called) diff --git a/zunclient/tests/unit/v1/test_containers.py b/zunclient/tests/unit/v1/test_containers.py index c806fe3b..6a000520 100644 --- a/zunclient/tests/unit/v1/test_containers.py +++ b/zunclient/tests/unit/v1/test_containers.py @@ -55,6 +55,8 @@ force_delete2 = True signal = "SIGTERM" name = "new-name" timeout = 10 +tty_height = "56" +tty_weight = "121" fake_responses = { '/v1/containers': @@ -211,6 +213,22 @@ fake_responses = { None, ), }, + '/v1/containers/%s/attach' % CONTAINER1['id']: + { + 'GET': ( + {}, + None, + ), + }, + '/v1/containers/%s/resize?%s' + % (CONTAINER1['id'], parse.urlencode({'h': tty_height, + 'w': tty_weight})): + { + 'POST': ( + {}, + None, + ), + }, } @@ -445,3 +463,23 @@ class ContainerManagerTest(testtools.TestCase): ] self.assertEqual(expect, self.api.calls) self.assertTrue(containers) + + def test_containers_attach(self): + containers = self.mgr.attach(CONTAINER1['id']) + expect = [ + ('GET', '/v1/containers/%s/attach' % CONTAINER1['id'], + {'Content-Length': '0'}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertIsNone(containers) + + def test_containers_resize(self): + containers = self.mgr.resize(CONTAINER1['id'], tty_weight, tty_height) + expect = [ + ('POST', '/v1/containers/%s/resize?%s' + % (CONTAINER1['id'], parse.urlencode({'h': tty_height, + 'w': tty_weight})), + {'Content-Length': '0'}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertIsNone(containers) diff --git a/zunclient/v1/containers.py b/zunclient/v1/containers.py index 5b2770d6..ff1bb4b8 100644 --- a/zunclient/v1/containers.py +++ b/zunclient/v1/containers.py @@ -158,3 +158,10 @@ class ContainerManager(base.Manager): def update(self, id, **patch): return self._update(self._path(id), patch) + + def attach(self, id): + return self._action(id, '/attach', method='GET')[1] + + def resize(self, id, width, height): + return self._action(id, '/resize', + qparams={'w': width, 'h': height})[1] diff --git a/zunclient/v1/containers_shell.py b/zunclient/v1/containers_shell.py index 3fde39ee..d2f8a3dd 100644 --- a/zunclient/v1/containers_shell.py +++ b/zunclient/v1/containers_shell.py @@ -17,6 +17,8 @@ import json from zunclient.common import cliutils as utils from zunclient.common import utils as zun_utils +from zunclient.common.websocketclient import exceptions +from zunclient.common.websocketclient import websocketclient from zunclient import exceptions as exc @@ -429,3 +431,26 @@ def do_update(cs, args): raise exc.CommandError("You must update at least one property") container = cs.containers.update(args.container, **opts) _show_container(container) + + +@utils.arg('container', + metavar='', + help='ID or name of the container to be attahed to.') +def do_attach(cs, args): + """Attach to a container.""" + response = cs.containers.attach(args.container) + if response.startswith("ws://"): + try: + wscls = websocketclient.WebSocketClient(host_url=response, + id=args.container, + escape="~", + close_wait=0.5) + wscls.init_httpclient() + wscls.connect() + wscls.handle_resize() + wscls.start_loop() + except exceptions.ContainerWebSocketException as e: + print("%(e)s:%(container)s" % + {'e': e, 'container': args.container}) + else: + raise exceptions.InvalidWebSocketLink(args.container)