From c48ee5caef70638f9174283fdccd01edceebd205 Mon Sep 17 00:00:00 2001 From: Daniel Nephin Date: Fri, 8 Aug 2014 09:41:52 -0700 Subject: [PATCH] Add a new fig command for retrieving the locally bound port of a service. Signed-off-by: Daniel Nephin --- docs/cli.md | 4 ++ fig/cli/errors.py | 2 + fig/cli/main.py | 21 +++++++ fig/container.py | 29 +++++---- fig/service.py | 15 ++++- tests/fixtures/ports-figfile/fig.yml | 7 +++ tests/integration/cli_test.py | 24 ++++++-- tests/unit/container_test.py | 88 +++++++++++++++++----------- tests/unit/service_test.py | 25 +++++++- 9 files changed, 164 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/ports-figfile/fig.yml diff --git a/docs/cli.md b/docs/cli.md index 8e1c50464..6b4497e78 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,6 +28,10 @@ Force stop service containers. View output from services. +## port + +Print the public port for a port binding + ## ps List containers. diff --git a/fig/cli/errors.py b/fig/cli/errors.py index 71a07403b..c61ab8a5f 100644 --- a/fig/cli/errors.py +++ b/fig/cli/errors.py @@ -9,6 +9,8 @@ class UserError(Exception): def __unicode__(self): return self.msg + __str__ = __unicode__ + class DockerNotFoundMac(UserError): def __init__(self): diff --git a/fig/cli/main.py b/fig/cli/main.py index eb19d46d8..4ced1f981 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -84,6 +84,7 @@ class TopLevelCommand(Command): help Get help on a command kill Kill containers logs View output from containers + port Print the public port for a port binding ps List containers rm Remove stopped containers run Run a one-off command @@ -148,6 +149,26 @@ class TopLevelCommand(Command): print("Attaching to", list_containers(containers)) LogPrinter(containers, attach_params={'logs': True}, monochrome=monochrome).run() + def port(self, project, options): + """ + Print the public port for a port binding. + + Usage: port [options] SERVICE PRIVATE_PORT + + Options: + --protocol=proto tcp or udp (defaults to tcp) + --index=index index of the container if there are multiple + instances of a service (defaults to 1) + """ + service = project.get_service(options['SERVICE']) + try: + container = service.get_container(number=options.get('--index') or 1) + except ValueError as e: + raise UserError(str(e)) + print(container.get_local_port( + options['PRIVATE_PORT'], + protocol=options.get('--protocol') or 'tcp') or '') + def ps(self, project, options): """ List containers. diff --git a/fig/container.py b/fig/container.py index dc3366a6e..d20de3de9 100644 --- a/fig/container.py +++ b/fig/container.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals from __future__ import absolute_import +from fig.packages import six + class Container(object): """ @@ -63,17 +65,20 @@ class Container(object): return None @property - def human_readable_ports(self): + def ports(self): self.inspect_if_not_inspected() - if not self.dictionary['NetworkSettings']['Ports']: - return '' - ports = [] - for private, public in list(self.dictionary['NetworkSettings']['Ports'].items()): - if public: - ports.append('%s->%s' % (public[0]['HostPort'], private)) - else: - ports.append(private) - return ', '.join(ports) + return self.dictionary['NetworkSettings']['Ports'] or {} + + @property + def human_readable_ports(self): + def format_port(private, public): + if not public: + return private + return '{HostIp}:{HostPort}->{private}'.format( + private=private, **public[0]) + + return ', '.join(format_port(*item) + for item in sorted(six.iteritems(self.ports))) @property def human_readable_state(self): @@ -105,6 +110,10 @@ class Container(object): self.inspect_if_not_inspected() return self.dictionary['State']['Running'] + def get_local_port(self, port, protocol='tcp'): + port = self.ports.get("%s/%s" % (port, protocol)) + return "{HostIp}:{HostPort}".format(**port[0]) if port else None + def start(self, **options): return self.client.start(self.id, **options) diff --git a/fig/service.py b/fig/service.py index bbb1bb669..4ce550faa 100644 --- a/fig/service.py +++ b/fig/service.py @@ -78,9 +78,22 @@ class Service(object): name = get_container_name(container) if not name or not is_valid_name(name, one_off): return False - project, name, number = parse_name(name) + project, name, _number = parse_name(name) return project == self.project and name == self.name + def get_container(self, number=1): + """Return a :class:`fig.container.Container` for this service. The + container must be active, and match `number`. + """ + for container in self.client.containers(): + if not self.has_container(container): + continue + _, _, container_number = parse_name(get_container_name(container)) + if container_number == number: + return Container.from_ps(self.client, container) + + raise ValueError("No container found for %s_%s" % (self.name, number)) + def start(self, **options): for c in self.containers(stopped=True): self.start_container_if_stopped(c, **options) diff --git a/tests/fixtures/ports-figfile/fig.yml b/tests/fixtures/ports-figfile/fig.yml new file mode 100644 index 000000000..5ff08d339 --- /dev/null +++ b/tests/fixtures/ports-figfile/fig.yml @@ -0,0 +1,7 @@ + +simple: + image: busybox:latest + command: /bin/sleep 300 + ports: + - '3000' + - '9999:3001' diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index d0c8585ea..273293339 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,10 +1,12 @@ from __future__ import absolute_import -from .testcases import DockerClientTestCase -from mock import patch -from fig.cli.main import TopLevelCommand -from fig.packages.six import StringIO import sys +from fig.packages.six import StringIO +from mock import patch + +from .testcases import DockerClientTestCase +from fig.cli.main import TopLevelCommand + class CLITestCase(DockerClientTestCase): def setUp(self): @@ -213,3 +215,17 @@ class CLITestCase(DockerClientTestCase): self.command.scale(project, {'SERVICE=NUM': ['simple=0', 'another=0']}) self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) + + def test_port(self): + self.command.base_dir = 'tests/fixtures/ports-figfile' + self.command.dispatch(['up', '-d'], None) + container = self.project.get_service('simple').get_container() + + @patch('sys.stdout', new_callable=StringIO) + def get_port(number, mock_stdout): + self.command.dispatch(['port', 'simple', str(number)], None) + return mock_stdout.getvalue().rstrip() + + self.assertEqual(get_port(3000), container.get_local_port(3000)) + self.assertEqual(get_port(3001), "0.0.0.0:9999") + self.assertEqual(get_port(3002), "") diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 9f4861891..22d2ca278 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -8,18 +8,28 @@ from fig.container import Container class ContainerTest(unittest.TestCase): + + + def setUp(self): + self.container_dict = { + "Id": "abc", + "Image": "busybox:latest", + "Command": "sleep 300", + "Created": 1387384730, + "Status": "Up 8 seconds", + "Ports": None, + "SizeRw": 0, + "SizeRootFs": 0, + "Names": ["/figtest_db_1"], + "NetworkSettings": { + "Ports": {}, + }, + } + def test_from_ps(self): - container = Container.from_ps(None, { - "Id":"abc", - "Image":"busybox:latest", - "Command":"sleep 300", - "Created":1387384730, - "Status":"Up 8 seconds", - "Ports":None, - "SizeRw":0, - "SizeRootFs":0, - "Names":["/figtest_db_1"] - }, has_been_inspected=True) + container = Container.from_ps(None, + self.container_dict, + has_been_inspected=True) self.assertEqual(container.dictionary, { "Id": "abc", "Image":"busybox:latest", @@ -42,35 +52,21 @@ class ContainerTest(unittest.TestCase): }) def test_number(self): - container = Container.from_ps(None, { - "Id":"abc", - "Image":"busybox:latest", - "Command":"sleep 300", - "Created":1387384730, - "Status":"Up 8 seconds", - "Ports":None, - "SizeRw":0, - "SizeRootFs":0, - "Names":["/figtest_db_1"] - }, has_been_inspected=True) + container = Container.from_ps(None, + self.container_dict, + has_been_inspected=True) self.assertEqual(container.number, 1) def test_name(self): - container = Container.from_ps(None, { - "Id":"abc", - "Image":"busybox:latest", - "Command":"sleep 300", - "Names":["/figtest_db_1"] - }, has_been_inspected=True) + container = Container.from_ps(None, + self.container_dict, + has_been_inspected=True) self.assertEqual(container.name, "figtest_db_1") def test_name_without_project(self): - container = Container.from_ps(None, { - "Id":"abc", - "Image":"busybox:latest", - "Command":"sleep 300", - "Names":["/figtest_db_1"] - }, has_been_inspected=True) + container = Container.from_ps(None, + self.container_dict, + has_been_inspected=True) self.assertEqual(container.name_without_project, "db_1") def test_inspect_if_not_inspected(self): @@ -85,3 +81,27 @@ class ContainerTest(unittest.TestCase): container.inspect_if_not_inspected() self.assertEqual(mock_client.inspect_container.call_count, 1) + + def test_human_readable_ports_none(self): + container = Container(None, self.container_dict, has_been_inspected=True) + self.assertEqual(container.human_readable_ports, '') + + def test_human_readable_ports_public_and_private(self): + self.container_dict['NetworkSettings']['Ports'].update({ + "45454/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "49197" } ], + "45453/tcp": [], + }) + container = Container(None, self.container_dict, has_been_inspected=True) + + expected = "45453/tcp, 0.0.0.0:49197->45454/tcp" + self.assertEqual(container.human_readable_ports, expected) + + def test_get_local_port(self): + self.container_dict['NetworkSettings']['Ports'].update({ + "45454/tcp": [ { "HostIp": "0.0.0.0", "HostPort": "49197" } ], + }) + container = Container(None, self.container_dict, has_been_inspected=True) + + self.assertEqual( + container.get_local_port(45454, protocol='tcp'), + '0.0.0.0:49197') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 84f589b2d..74376c399 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -5,6 +5,8 @@ import os from .. import unittest import mock +from fig.packages import docker + from fig import Service from fig.service import ( ConfigError, @@ -97,14 +99,33 @@ class ServiceTest(unittest.TestCase): def test_split_domainname_weird(self): service = Service('foo', - hostname = 'name.sub', - domainname = 'domain.tld', + hostname='name.sub', + domainname='domain.tld', ) service.next_container_name = lambda x: 'foo' opts = service._get_container_create_options({}) self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') + def test_get_container_not_found(self): + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [] + service = Service('foo', client=mock_client) + + self.assertRaises(ValueError, service.get_container) + + @mock.patch('fig.service.Container', autospec=True) + def test_get_container(self, mock_container_class): + mock_client = mock.create_autospec(docker.Client) + container_dict = dict(Name='default_foo_2') + mock_client.containers.return_value = [container_dict] + service = Service('foo', client=mock_client) + + container = service.get_container(number=2) + self.assertEqual(container, mock_container_class.from_ps.return_value) + mock_container_class.from_ps.assert_called_once_with( + mock_client, container_dict) + class ServiceVolumesTest(unittest.TestCase):