diff --git a/docs/cli.md b/docs/cli.md index 9eb87f1f8..a697ab7ae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -45,7 +45,7 @@ For example: $ 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. @@ -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" +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 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. +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. -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/ diff --git a/fig/cli/main.py b/fig/cli/main.py index f58986458..9e4e46699 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -202,21 +202,30 @@ class TopLevelCommand(Command): $ 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...] 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']) + if not options['--no-deps']: + self.project.up( + service_names=service.get_linked_names(), + start_links=True, + recreate=False + ) + tty = True if options['-d'] or options['-T'] or not sys.stdin.isatty(): tty = False @@ -293,17 +302,29 @@ class TopLevelCommand(Command): 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. + 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...] 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'] - 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: print("Attaching to", list_containers(to_attach)) @@ -313,12 +334,12 @@ class TopLevelCommand(Command): log_printer.run() finally: def handler(signal, frame): - self.project.kill(service_names=options['SERVICE']) + self.project.kill(service_names=service_names) sys.exit(0) signal.signal(signal.SIGINT, handler) 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): socket_in = self.client.attach_socket(container_id, params={'stdin': 1, 'stream': 1}) diff --git a/fig/project.py b/fig/project.py index b271a810a..a3b78f5d5 100644 --- a/fig/project.py +++ b/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)) del service_dict['links'] + project.services.append(Service(client=client, project=name, links=links, **service_dict)) return project @@ -88,22 +89,35 @@ class Project(object): 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 - 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 []. - Preserves the original order of self.services. + If include_links is specified, returns a list including the links for + service_names, in order of dependency. - Raises NoSuchService if any of the named services - do not exist. + 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. """ 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: 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): for service in self.get_services(service_names): @@ -124,14 +138,18 @@ class Project(object): else: 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): for service in self.get_services(service_names): @@ -144,6 +162,20 @@ class Project(object): l.append(container) 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): def __init__(self, name): diff --git a/fig/service.py b/fig/service.py index 7d1f0acbb..54c6fa2db 100644 --- a/fig/service.py +++ b/fig/service.py @@ -75,9 +75,7 @@ class Service(object): def start(self, **options): 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): for c in self.containers(): @@ -200,6 +198,13 @@ class Service(object): 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): if container is None: container = self.create_container(**override_options) @@ -243,6 +248,19 @@ class Service(object): ) 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): bits = [self.project, self.name] if one_off: diff --git a/tests/fixtures/links-figfile/fig.yml b/tests/fixtures/links-figfile/fig.yml new file mode 100644 index 000000000..bc5391a99 --- /dev/null +++ b/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 diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 125b018ed..87c9680ba 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -1,17 +1,20 @@ -from __future__ import unicode_literals 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 class CLITestCase(DockerClientTestCase): def setUp(self): super(CLITestCase, self).setUp() + self.old_sys_exit = sys.exit + sys.exit = lambda code=0: None self.command = TopLevelCommand() self.command.base_dir = 'tests/fixtures/simple-figfile' def tearDown(self): + sys.exit = self.old_sys_exit self.command.project.kill() self.command.project.remove_stopped() @@ -43,6 +46,100 @@ class CLITestCase(DockerClientTestCase): self.assertNotIn('fig_another_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): service = self.command.project.get_service('simple') service.create_container() diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index fa7e38586..f52476828 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -44,6 +44,21 @@ class ProjectTest(DockerClientTestCase): project.start() 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']) self.assertEqual(len(project.containers()), 1) old_db_id = project.containers()[0].id @@ -59,6 +74,107 @@ class ProjectTest(DockerClientTestCase): project.kill() 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): web = self.create_service('web') project = Project('figtest', [web], self.client) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index bba2e1f51..ce80333f2 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -67,3 +67,68 @@ class ProjectTest(unittest.TestCase): ) project = Project('test', [web], None) 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] + )