diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 8105d3b3feb..be37afe99d7 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -34,7 +34,11 @@ def parse(self, argv, global_options): raise SystemExit(getdoc(self)) if not hasattr(self, command): - raise NoSuchCommand(command, self) + # A command can be a reserved word, like exec + if hasattr(self, "%s_" % command): + command = "%s_" % command + else: + raise NoSuchCommand(command, self) handler = getattr(self, command) docstring = getdoc(handler) diff --git a/compose/cli/main.py b/compose/cli/main.py index 95dfb6cbd36..bfd69e80304 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -90,6 +90,7 @@ class TopLevelCommand(Command): pull Pulls service images rm Remove stopped containers run Run a one-off command + exec Run a command in a service scale Set number of containers for a service start Start services stop Stop services @@ -349,6 +350,56 @@ def run(self, project, options): project.client.remove_container(container.id) sys.exit(exit_code) + # exec is Python reserved word, redirected in docopt_command.py + def exec_(self, project, options): + """ + Run a command in a running service + + For example: + $ docker-compose exec web ps aux + + To run against all services: + $ docker-compose exec --all ps aux + + Usage: exec [options] [ SERVICE CMD... | CMD... ] + + Options: + -a, --all Run against all containers of all services + -d, --detach Detached mode: run command in the background. + (default: false) + """ + service = options['SERVICE'] + cmd = options['CMD'] + + # Should we run against all containers? + if options['--all']: + containers = sorted( + project.containers() + + project.containers(one_off=True), + key=attrgetter('name')) + + # If we have a service it should be in the command + if service is not None: + cmd.insert(0, service) + else: + containers = sorted( + project.containers(service_names=[service]) + + project.containers(service_names=[service], one_off=True), + key=attrgetter('name')) + + detach = options.get('--detach') + + # Run the command against all containers + for container in containers: + result = container.execute(cmd, detach=detach) + + # only show container name in detach mode + if detach: + print(container.name) + else: + print("----- %s -----\n" % container.name) + print(result) + def scale(self, project, options): """ Set number of containers to run for a service. diff --git a/compose/container.py b/compose/container.py index 1d044a421ce..4170df79606 100644 --- a/compose/container.py +++ b/compose/container.py @@ -156,6 +156,9 @@ def links(self): links.append(bits[2]) return links + def execute(self, *args, **kwargs): + return self.client.execute(self.id, *args, **kwargs) + def attach(self, *args, **kwargs): return self.client.attach(self.id, *args, **kwargs) diff --git a/tests/fixtures/long-running/Dockerfile b/tests/fixtures/long-running/Dockerfile new file mode 100644 index 00000000000..79b85e0f155 --- /dev/null +++ b/tests/fixtures/long-running/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +CMD sleep 300 diff --git a/tests/fixtures/long-running/docker-compose.yml b/tests/fixtures/long-running/docker-compose.yml new file mode 100644 index 00000000000..c8f1e9204b8 --- /dev/null +++ b/tests/fixtures/long-running/docker-compose.yml @@ -0,0 +1,5 @@ +longrunning: + build: tests/fixtures/long-running + +longrunning2: + build: tests/fixtures/long-running diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index df3eec66d08..5f59f47490a 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -318,6 +318,59 @@ def test_run_service_with_map_ports(self, __): self.assertIn("0.0.0.0", port_random) self.assertEqual(port_assigned, "0.0.0.0:49152") + + @patch('sys.stdout', new_callable=StringIO) + def test_exec_one(self, mock_stdout): + """ + Execute a command in a single container for a single service + """ + self.command.base_dir = 'tests/fixtures/long-running' + self.command.dispatch(['up', '-d', 'longrunning'], None) + + # Validate the start + service = self.project.get_service('longrunning') + self.assertEqual(len(service.containers()), 1) + + self.command.dispatch(['exec','longrunning','echo','Hello World!'], None) + output = mock_stdout.getvalue() + self.assertIn('Hello World!',output) + + @patch('sys.stdout', new_callable=StringIO) + def test_exec_multiple(self, mock_stdout): + """ + Execute a command in a single container for a single service + """ + self.command.base_dir = 'tests/fixtures/long-running' + self.command.dispatch(['up', '-d', 'longrunning'], None) + self.command.dispatch(['scale', 'longrunning=2'], None) + + # Validate the start/scaling + service = self.project.get_service('longrunning') + self.assertEqual(len(service.containers()), 2) + + self.command.dispatch(['exec','longrunning','echo','Hello World!'], None) + output = mock_stdout.getvalue() + self.assertEqual(output.count('Hello World!'), 2) + + @patch('sys.stdout', new_callable=StringIO) + def test_exec_all(self, mock_stdout): + """ + Execute a command in a multiple containers for a multple service + """ + self.command.base_dir = 'tests/fixtures/long-running' + self.command.dispatch(['up', '-d'], None) + self.command.dispatch(['scale', 'longrunning=2', 'longrunning2=3'], None) + + # Validate the start/scaling + service = self.project.get_service('longrunning') + self.assertEqual(len(service.containers()), 2) + service2 = self.project.get_service('longrunning2') + self.assertEqual(len(service2.containers()), 3) + + self.command.dispatch(['exec','--all','echo','Hello World!'], None) + output = mock_stdout.getvalue() + self.assertEqual(output.count('Hello World!'), 5) + def test_rm(self): service = self.project.get_service('simple') service.create_container()