浏览代码

Merge pull request #246 from d11wtq/feature/auto_start

Feature: `fig up` and `fig run` now start linked containers (closes #31).
Ben Firshman 11 年之前
父节点
当前提交
95aa61cfe5
共有 8 个文件被更改,包括 400 次插入34 次删除
  1. 8 2
      docs/cli.md
  2. 35 14
      fig/cli/main.py
  3. 46 14
      fig/project.py
  4. 21 3
      fig/service.py
  5. 11 0
      tests/fixtures/links-figfile/fig.yml
  6. 98 1
      tests/integration/cli_test.py
  7. 116 0
      tests/integration/project_test.py
  8. 65 0
      tests/unit/project_test.py

+ 8 - 2
docs/cli.md

@@ -45,7 +45,7 @@ For example:
 
 
     $ fig run web python manage.py shell
     $ fig run web python manage.py shell
 
 
-Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first.
+By default, linked services will be started, unless they are already running.
 
 
 One-off commands are started in new containers with the same config as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and no ports will be created in case they collide.
 One-off commands are started in new containers with the same config as a normal container for that service, so volumes, links, etc will all be created as expected. The only thing different to a normal container is the command will be overridden with the one specified and no ports will be created in case they collide.
 
 
@@ -53,6 +53,10 @@ Links are also created between one-off commands and the other containers for tha
 
 
     $ fig run db /bin/sh -c "psql -h \$DB_1_PORT_5432_TCP_ADDR -U docker"
     $ fig run db /bin/sh -c "psql -h \$DB_1_PORT_5432_TCP_ADDR -U docker"
 
 
+If you do not want linked containers to be started when running the one-off command, specify the `--no-deps` flag:
+
+    $ fig run --no-deps web python manage.py shell
+
 ## scale
 ## scale
 
 
 Set number of containers to run for a service.
 Set number of containers to run for a service.
@@ -74,8 +78,10 @@ Stop running containers without removing them. They can be started again with `f
 
 
 Build, (re)create, start and attach to containers for a service.
 Build, (re)create, start and attach to containers for a service.
 
 
+Linked services will be started, unless they are already running.
+
 By default, `fig up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `fig up -d`, it'll start the containers in the background and leave them running.
 By default, `fig up` will aggregate the output of each container, and when it exits, all containers will be stopped. If you run `fig up -d`, it'll start the containers in the background and leave them running.
 
 
-If there are existing containers for a service, `fig up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `fig.yml` are picked up.
+By default if there are existing containers for a service, `fig up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `fig.yml` are picked up. If you do no want containers to be stopped and recreated, use `fig up --no-recreate`. This will still start any stopped containers, if needed.
 
 
 [volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/
 [volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/

+ 35 - 14
fig/cli/main.py

@@ -202,21 +202,30 @@ class TopLevelCommand(Command):
 
 
             $ fig run web python manage.py shell
             $ fig run web python manage.py shell
 
 
-        Note that this will not start any services that the command's service
-        links to. So if, for example, your one-off command talks to your
-        database, you will need to run `fig up -d db` first.
+        By default, linked services will be started, unless they are already
+        running. If you do not want to start linked services, use
+        `fig run --no-deps SERVICE COMMAND [ARGS...]`.
 
 
         Usage: run [options] SERVICE COMMAND [ARGS...]
         Usage: run [options] SERVICE COMMAND [ARGS...]
 
 
         Options:
         Options:
-            -d    Detached mode: Run container in the background, print new
-                  container name
-            -T    Disable pseudo-tty allocation. By default `fig run`
-                  allocates a TTY.
-            --rm  Remove container after run. Ignored in detached mode.
+            -d         Detached mode: Run container in the background, print
+                       new container name.
+            -T         Disable pseudo-tty allocation. By default `fig run`
+                       allocates a TTY.
+            --rm       Remove container after run. Ignored in detached mode.
+            --no-deps  Don't start linked services.
         """
         """
+
         service = self.project.get_service(options['SERVICE'])
         service = self.project.get_service(options['SERVICE'])
 
 
+        if not options['--no-deps']:
+            self.project.up(
+                service_names=service.get_linked_names(),
+                start_links=True,
+                recreate=False
+            )
+
         tty = True
         tty = True
         if options['-d'] or options['-T'] or not sys.stdin.isatty():
         if options['-d'] or options['-T'] or not sys.stdin.isatty():
             tty = False
             tty = False
@@ -293,17 +302,29 @@ class TopLevelCommand(Command):
 
 
         If there are existing containers for a service, `fig up` will stop
         If there are existing containers for a service, `fig up` will stop
         and recreate them (preserving mounted volumes with volumes-from),
         and recreate them (preserving mounted volumes with volumes-from),
-        so that changes in `fig.yml` are picked up.
+        so that changes in `fig.yml` are picked up. If you do not want existing
+        containers to be recreated, `fig up --no-recreate` will re-use existing
+        containers.
 
 
         Usage: up [options] [SERVICE...]
         Usage: up [options] [SERVICE...]
 
 
         Options:
         Options:
-            -d    Detached mode: Run containers in the background, print new
-                  container names
+            -d             Detached mode: Run containers in the background,
+                           print new container names.
+            --no-deps      Don't start linked services.
+            --no-recreate  If containers already exist, don't recreate them.
         """
         """
         detached = options['-d']
         detached = options['-d']
 
 
-        to_attach = self.project.up(service_names=options['SERVICE'])
+        start_links = not options['--no-deps']
+        recreate = not options['--no-recreate']
+        service_names = options['SERVICE']
+
+        to_attach = self.project.up(
+            service_names=service_names,
+            start_links=start_links,
+            recreate=recreate
+        )
 
 
         if not detached:
         if not detached:
             print("Attaching to", list_containers(to_attach))
             print("Attaching to", list_containers(to_attach))
@@ -313,12 +334,12 @@ class TopLevelCommand(Command):
                 log_printer.run()
                 log_printer.run()
             finally:
             finally:
                 def handler(signal, frame):
                 def handler(signal, frame):
-                    self.project.kill(service_names=options['SERVICE'])
+                    self.project.kill(service_names=service_names)
                     sys.exit(0)
                     sys.exit(0)
                 signal.signal(signal.SIGINT, handler)
                 signal.signal(signal.SIGINT, handler)
 
 
                 print("Gracefully stopping... (press Ctrl+C again to force)")
                 print("Gracefully stopping... (press Ctrl+C again to force)")
-                self.project.stop(service_names=options['SERVICE'])
+                self.project.stop(service_names=service_names)
 
 
     def _attach_to_container(self, container_id, raw=False):
     def _attach_to_container(self, container_id, raw=False):
         socket_in = self.client.attach_socket(container_id, params={'stdin': 1, 'stream': 1})
         socket_in = self.client.attach_socket(container_id, params={'stdin': 1, 'stream': 1})

+ 46 - 14
fig/project.py

@@ -64,6 +64,7 @@ class Project(object):
                         raise ConfigurationError('Service "%s" has a link to service "%s" which does not exist.' % (service_dict['name'], service_name))
                         raise ConfigurationError('Service "%s" has a link to service "%s" which does not exist.' % (service_dict['name'], service_name))
 
 
                 del service_dict['links']
                 del service_dict['links']
+
             project.services.append(Service(client=client, project=name, links=links, **service_dict))
             project.services.append(Service(client=client, project=name, links=links, **service_dict))
         return project
         return project
 
 
@@ -88,22 +89,35 @@ class Project(object):
 
 
         raise NoSuchService(name)
         raise NoSuchService(name)
 
 
-    def get_services(self, service_names=None):
+    def get_services(self, service_names=None, include_links=False):
         """
         """
         Returns a list of this project's services filtered
         Returns a list of this project's services filtered
-        by the provided list of names, or all services if
-        service_names is None or [].
+        by the provided list of names, or all services if service_names is None
+        or [].
+
+        If include_links is specified, returns a list including the links for
+        service_names, in order of dependency.
 
 
-        Preserves the original order of self.services.
+        Preserves the original order of self.services where possible,
+        reordering as needed to resolve links.
 
 
-        Raises NoSuchService if any of the named services
-        do not exist.
+        Raises NoSuchService if any of the named services do not exist.
         """
         """
         if service_names is None or len(service_names) == 0:
         if service_names is None or len(service_names) == 0:
-            return self.services
+            return self.get_services(
+                service_names=[s.name for s in self.services],
+                include_links=include_links
+            )
         else:
         else:
             unsorted = [self.get_service(name) for name in service_names]
             unsorted = [self.get_service(name) for name in service_names]
-            return [s for s in self.services if s in unsorted]
+            services = [s for s in self.services if s in unsorted]
+
+            if include_links:
+                services = reduce(self._inject_links, services, [])
+
+            uniques = []
+            [uniques.append(s) for s in services if s not in uniques]
+            return uniques
 
 
     def start(self, service_names=None, **options):
     def start(self, service_names=None, **options):
         for service in self.get_services(service_names):
         for service in self.get_services(service_names):
@@ -124,14 +138,18 @@ class Project(object):
             else:
             else:
                 log.info('%s uses an image, skipping' % service.name)
                 log.info('%s uses an image, skipping' % service.name)
 
 
-    def up(self, service_names=None):
-        new_containers = []
+    def up(self, service_names=None, start_links=True, recreate=True):
+        running_containers = []
 
 
-        for service in self.get_services(service_names):
-            for (_, new) in service.recreate_containers():
-                new_containers.append(new)
+        for service in self.get_services(service_names, include_links=start_links):
+            if recreate:
+                for (_, container) in service.recreate_containers():
+                    running_containers.append(container)
+            else:
+                for container in service.start_or_create_containers():
+                    running_containers.append(container)
 
 
-        return new_containers
+        return running_containers
 
 
     def remove_stopped(self, service_names=None, **options):
     def remove_stopped(self, service_names=None, **options):
         for service in self.get_services(service_names):
         for service in self.get_services(service_names):
@@ -144,6 +162,20 @@ class Project(object):
                 l.append(container)
                 l.append(container)
         return l
         return l
 
 
+    def _inject_links(self, acc, service):
+        linked_names = service.get_linked_names()
+
+        if len(linked_names) > 0:
+            linked_services = self.get_services(
+                service_names=linked_names,
+                include_links=True
+            )
+        else:
+            linked_services = []
+
+        linked_services.append(service)
+        return acc + linked_services
+
 
 
 class NoSuchService(Exception):
 class NoSuchService(Exception):
     def __init__(self, name):
     def __init__(self, name):

+ 21 - 3
fig/service.py

@@ -75,9 +75,7 @@ class Service(object):
 
 
     def start(self, **options):
     def start(self, **options):
         for c in self.containers(stopped=True):
         for c in self.containers(stopped=True):
-            if not c.is_running:
-                log.info("Starting %s..." % c.name)
-                self.start_container(c, **options)
+            self.start_container_if_stopped(c, **options)
 
 
     def stop(self, **options):
     def stop(self, **options):
         for c in self.containers():
         for c in self.containers():
@@ -200,6 +198,13 @@ class Service(object):
 
 
         return (intermediate_container, new_container)
         return (intermediate_container, new_container)
 
 
+    def start_container_if_stopped(self, container, **options):
+        if container.is_running:
+            return container
+        else:
+            log.info("Starting %s..." % container.name)
+            return self.start_container(container, **options)
+
     def start_container(self, container=None, volumes_from=None, **override_options):
     def start_container(self, container=None, volumes_from=None, **override_options):
         if container is None:
         if container is None:
             container = self.create_container(**override_options)
             container = self.create_container(**override_options)
@@ -243,6 +248,19 @@ class Service(object):
         )
         )
         return container
         return container
 
 
+    def start_or_create_containers(self):
+        containers = self.containers(stopped=True)
+
+        if len(containers) == 0:
+            log.info("Creating %s..." % self.next_container_name())
+            new_container = self.create_container()
+            return [self.start_container(new_container)]
+        else:
+            return [self.start_container_if_stopped(c) for c in containers]
+
+    def get_linked_names(self):
+        return [s.name for (s, _) in self.links]
+
     def next_container_name(self, one_off=False):
     def next_container_name(self, one_off=False):
         bits = [self.project, self.name]
         bits = [self.project, self.name]
         if one_off:
         if one_off:

+ 11 - 0
tests/fixtures/links-figfile/fig.yml

@@ -0,0 +1,11 @@
+db:
+  image: busybox:latest
+  command: /bin/sleep 300
+web:
+  image: busybox:latest
+  command: /bin/sleep 300
+  links:
+    - db:db
+console:
+  image: busybox:latest
+  command: /bin/sleep 300

+ 98 - 1
tests/integration/cli_test.py

@@ -1,17 +1,20 @@
-from __future__ import unicode_literals
 from __future__ import absolute_import
 from __future__ import absolute_import
 from .testcases import DockerClientTestCase
 from .testcases import DockerClientTestCase
 from mock import patch
 from mock import patch
 from fig.cli.main import TopLevelCommand
 from fig.cli.main import TopLevelCommand
 from fig.packages.six import StringIO
 from fig.packages.six import StringIO
+import sys
 
 
 class CLITestCase(DockerClientTestCase):
 class CLITestCase(DockerClientTestCase):
     def setUp(self):
     def setUp(self):
         super(CLITestCase, self).setUp()
         super(CLITestCase, self).setUp()
+        self.old_sys_exit = sys.exit
+        sys.exit = lambda code=0: None
         self.command = TopLevelCommand()
         self.command = TopLevelCommand()
         self.command.base_dir = 'tests/fixtures/simple-figfile'
         self.command.base_dir = 'tests/fixtures/simple-figfile'
 
 
     def tearDown(self):
     def tearDown(self):
+        sys.exit = self.old_sys_exit
         self.command.project.kill()
         self.command.project.kill()
         self.command.project.remove_stopped()
         self.command.project.remove_stopped()
 
 
@@ -43,6 +46,100 @@ class CLITestCase(DockerClientTestCase):
         self.assertNotIn('fig_another_1', output)
         self.assertNotIn('fig_another_1', output)
         self.assertIn('fig_yetanother_1', output)
         self.assertIn('fig_yetanother_1', output)
 
 
+    def test_up(self):
+        self.command.dispatch(['up', '-d'], None)
+        service = self.command.project.get_service('simple')
+        another = self.command.project.get_service('another')
+        self.assertEqual(len(service.containers()), 1)
+        self.assertEqual(len(another.containers()), 1)
+
+    def test_up_with_links(self):
+        self.command.base_dir = 'tests/fixtures/links-figfile'
+        self.command.dispatch(['up', '-d', 'web'], None)
+        web = self.command.project.get_service('web')
+        db = self.command.project.get_service('db')
+        console = self.command.project.get_service('console')
+        self.assertEqual(len(web.containers()), 1)
+        self.assertEqual(len(db.containers()), 1)
+        self.assertEqual(len(console.containers()), 0)
+
+    def test_up_with_no_deps(self):
+        self.command.base_dir = 'tests/fixtures/links-figfile'
+        self.command.dispatch(['up', '-d', '--no-deps', 'web'], None)
+        web = self.command.project.get_service('web')
+        db = self.command.project.get_service('db')
+        console = self.command.project.get_service('console')
+        self.assertEqual(len(web.containers()), 1)
+        self.assertEqual(len(db.containers()), 0)
+        self.assertEqual(len(console.containers()), 0)
+
+    def test_up_with_recreate(self):
+        self.command.dispatch(['up', '-d'], None)
+        service = self.command.project.get_service('simple')
+        self.assertEqual(len(service.containers()), 1)
+
+        old_ids = [c.id for c in service.containers()]
+
+        self.command.dispatch(['up', '-d'], None)
+        self.assertEqual(len(service.containers()), 1)
+
+        new_ids = [c.id for c in service.containers()]
+
+        self.assertNotEqual(old_ids, new_ids)
+
+    def test_up_with_keep_old(self):
+        self.command.dispatch(['up', '-d'], None)
+        service = self.command.project.get_service('simple')
+        self.assertEqual(len(service.containers()), 1)
+
+        old_ids = [c.id for c in service.containers()]
+
+        self.command.dispatch(['up', '-d', '--no-recreate'], None)
+        self.assertEqual(len(service.containers()), 1)
+
+        new_ids = [c.id for c in service.containers()]
+
+        self.assertEqual(old_ids, new_ids)
+
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_run_with_links(self, mock_stdout):
+        mock_stdout.fileno = lambda: 1
+
+        self.command.base_dir = 'tests/fixtures/links-figfile'
+        self.command.dispatch(['run', 'web', '/bin/true'], None)
+        db = self.command.project.get_service('db')
+        console = self.command.project.get_service('console')
+        self.assertEqual(len(db.containers()), 1)
+        self.assertEqual(len(console.containers()), 0)
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_run_with_no_deps(self, mock_stdout):
+        mock_stdout.fileno = lambda: 1
+
+        self.command.base_dir = 'tests/fixtures/links-figfile'
+        self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None)
+        db = self.command.project.get_service('db')
+        self.assertEqual(len(db.containers()), 0)
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_run_does_not_recreate_linked_containers(self, mock_stdout):
+        mock_stdout.fileno = lambda: 1
+
+        self.command.base_dir = 'tests/fixtures/links-figfile'
+        self.command.dispatch(['up', '-d', 'db'], None)
+        db = self.command.project.get_service('db')
+        self.assertEqual(len(db.containers()), 1)
+
+        old_ids = [c.id for c in db.containers()]
+
+        self.command.dispatch(['run', 'web', '/bin/true'], None)
+        self.assertEqual(len(db.containers()), 1)
+
+        new_ids = [c.id for c in db.containers()]
+
+        self.assertEqual(old_ids, new_ids)
+
     def test_rm(self):
     def test_rm(self):
         service = self.command.project.get_service('simple')
         service = self.command.project.get_service('simple')
         service.create_container()
         service.create_container()

+ 116 - 0
tests/integration/project_test.py

@@ -44,6 +44,21 @@ class ProjectTest(DockerClientTestCase):
         project.start()
         project.start()
         self.assertEqual(len(project.containers()), 0)
         self.assertEqual(len(project.containers()), 0)
 
 
+        project.up(['db'])
+        self.assertEqual(len(project.containers()), 1)
+        self.assertEqual(len(db.containers()), 1)
+        self.assertEqual(len(web.containers()), 0)
+
+        project.kill()
+        project.remove_stopped()
+
+    def test_project_up_recreates_containers(self):
+        web = self.create_service('web')
+        db = self.create_service('db', volumes=['/var/db'])
+        project = Project('figtest', [web, db], self.client)
+        project.start()
+        self.assertEqual(len(project.containers()), 0)
+
         project.up(['db'])
         project.up(['db'])
         self.assertEqual(len(project.containers()), 1)
         self.assertEqual(len(project.containers()), 1)
         old_db_id = project.containers()[0].id
         old_db_id = project.containers()[0].id
@@ -59,6 +74,107 @@ class ProjectTest(DockerClientTestCase):
         project.kill()
         project.kill()
         project.remove_stopped()
         project.remove_stopped()
 
 
+    def test_project_up_with_no_recreate_running(self):
+        web = self.create_service('web')
+        db = self.create_service('db', volumes=['/var/db'])
+        project = Project('figtest', [web, db], self.client)
+        project.start()
+        self.assertEqual(len(project.containers()), 0)
+
+        project.up(['db'])
+        self.assertEqual(len(project.containers()), 1)
+        old_db_id = project.containers()[0].id
+        db_volume_path = project.containers()[0].inspect()['Volumes']['/var/db']
+
+        project.up(recreate=False)
+        self.assertEqual(len(project.containers()), 2)
+
+        db_container = [c for c in project.containers() if 'db' in c.name][0]
+        self.assertEqual(c.id, old_db_id)
+        self.assertEqual(c.inspect()['Volumes']['/var/db'], db_volume_path)
+
+        project.kill()
+        project.remove_stopped()
+
+    def test_project_up_with_no_recreate_stopped(self):
+        web = self.create_service('web')
+        db = self.create_service('db', volumes=['/var/db'])
+        project = Project('figtest', [web, db], self.client)
+        project.start()
+        self.assertEqual(len(project.containers()), 0)
+
+        project.up(['db'])
+        project.stop()
+
+        old_containers = project.containers(stopped=True)
+
+        self.assertEqual(len(old_containers), 1)
+        old_db_id = old_containers[0].id
+        db_volume_path = old_containers[0].inspect()['Volumes']['/var/db']
+
+        project.up(recreate=False)
+
+        new_containers = project.containers(stopped=True)
+        self.assertEqual(len(new_containers), 2)
+
+        db_container = [c for c in new_containers if 'db' in c.name][0]
+        self.assertEqual(c.id, old_db_id)
+        self.assertEqual(c.inspect()['Volumes']['/var/db'], db_volume_path)
+
+        project.kill()
+        project.remove_stopped()
+
+    def test_project_up_without_all_services(self):
+        console = self.create_service('console')
+        db = self.create_service('db')
+        project = Project('figtest', [console, db], self.client)
+        project.start()
+        self.assertEqual(len(project.containers()), 0)
+
+        project.up()
+        self.assertEqual(len(project.containers()), 2)
+        self.assertEqual(len(db.containers()), 1)
+        self.assertEqual(len(console.containers()), 1)
+
+        project.kill()
+        project.remove_stopped()
+
+    def test_project_up_starts_links(self):
+        console = self.create_service('console')
+        db = self.create_service('db', volumes=['/var/db'])
+        web = self.create_service('web', links=[(db, 'db')])
+
+        project = Project('figtest', [web, db, console], self.client)
+        project.start()
+        self.assertEqual(len(project.containers()), 0)
+
+        project.up(['web'])
+        self.assertEqual(len(project.containers()), 2)
+        self.assertEqual(len(web.containers()), 1)
+        self.assertEqual(len(db.containers()), 1)
+        self.assertEqual(len(console.containers()), 0)
+
+        project.kill()
+        project.remove_stopped()
+
+    def test_project_up_with_no_deps(self):
+        console = self.create_service('console')
+        db = self.create_service('db', volumes=['/var/db'])
+        web = self.create_service('web', links=[(db, 'db')])
+
+        project = Project('figtest', [web, db, console], self.client)
+        project.start()
+        self.assertEqual(len(project.containers()), 0)
+
+        project.up(['web'], start_links=False)
+        self.assertEqual(len(project.containers()), 1)
+        self.assertEqual(len(web.containers()), 1)
+        self.assertEqual(len(db.containers()), 0)
+        self.assertEqual(len(console.containers()), 0)
+
+        project.kill()
+        project.remove_stopped()
+
     def test_unscale_after_restart(self):
     def test_unscale_after_restart(self):
         web = self.create_service('web')
         web = self.create_service('web')
         project = Project('figtest', [web], self.client)
         project = Project('figtest', [web], self.client)

+ 65 - 0
tests/unit/project_test.py

@@ -67,3 +67,68 @@ class ProjectTest(unittest.TestCase):
         )
         )
         project = Project('test', [web], None)
         project = Project('test', [web], None)
         self.assertEqual(project.get_service('web'), web)
         self.assertEqual(project.get_service('web'), web)
+
+    def test_get_services_returns_all_services_without_args(self):
+        web = Service(
+            project='figtest',
+            name='web',
+        )
+        console = Service(
+            project='figtest',
+            name='console',
+        )
+        project = Project('test', [web, console], None)
+        self.assertEqual(project.get_services(), [web, console])
+
+    def test_get_services_returns_listed_services_with_args(self):
+        web = Service(
+            project='figtest',
+            name='web',
+        )
+        console = Service(
+            project='figtest',
+            name='console',
+        )
+        project = Project('test', [web, console], None)
+        self.assertEqual(project.get_services(['console']), [console])
+
+    def test_get_services_with_include_links(self):
+        db = Service(
+            project='figtest',
+            name='db',
+        )
+        web = Service(
+            project='figtest',
+            name='web',
+            links=[(db, 'database')]
+        )
+        cache = Service(
+            project='figtest',
+            name='cache'
+        )
+        console = Service(
+            project='figtest',
+            name='console',
+            links=[(web, 'web')]
+        )
+        project = Project('test', [web, db, cache, console], None)
+        self.assertEqual(
+            project.get_services(['console'], include_links=True),
+            [db, web, console]
+        )
+
+    def test_get_services_removes_duplicates_following_links(self):
+        db = Service(
+            project='figtest',
+            name='db',
+        )
+        web = Service(
+            project='figtest',
+            name='web',
+            links=[(db, 'database')]
+        )
+        project = Project('test', [web, db], None)
+        self.assertEqual(
+            project.get_services(['web', 'db'], include_links=True),
+            [db, web]
+        )