Make run behave in the same way as up

Signed-off-by: Eric Hripko <ehripko@bloomberg.net>
This commit is contained in:
Eric Hripko 2020-05-15 14:32:18 +01:00
parent 52d2fcc274
commit 06462cd604
6 changed files with 112 additions and 35 deletions

View File

@ -1298,31 +1298,28 @@ def build_one_off_container_options(options, detach, command):
def run_one_off_container(container_options, project, service, options, toplevel_options, def run_one_off_container(container_options, project, service, options, toplevel_options,
toplevel_environment): toplevel_environment):
if not options['--no-deps']: detach = options.get('--detach')
deps = service.get_dependency_names() use_network_aliases = options.get('--use-aliases')
if deps: containers = project.up(
project.up( service_names=[service.name],
service_names=deps, start_deps=not options['--no-deps'],
start_deps=True,
strategy=ConvergenceStrategy.never, strategy=ConvergenceStrategy.never,
rescale=False detached=detach,
) rescale=False,
project.initialize()
container = service.create_container(
quiet=True,
one_off=True, one_off=True,
**container_options) override_options=container_options,
)
try:
container = next(c for c in containers if c.service == service.name)
except StopIteration:
raise OperationFailedError('Could not bring up the requested service')
use_network_aliases = options['--use-aliases'] if detach:
if options.get('--detach'):
service.start_container(container, use_network_aliases) service.start_container(container, use_network_aliases)
print(container.name) print(container.name)
return return
def remove_container(force=False): def remove_container():
if options['--rm']: if options['--rm']:
project.client.remove_container(container.id, force=True, v=True) project.client.remove_container(container.id, force=True, v=True)
@ -1355,7 +1352,7 @@ def run_one_off_container(container_options, project, service, options, toplevel
exit_code = 1 exit_code = 1
except (signals.ShutdownException, signals.HangUpException): except (signals.ShutdownException, signals.HangUpException):
project.client.kill(container.id) project.client.kill(container.id)
remove_container(force=True) remove_container()
sys.exit(2) sys.exit(2)
remove_container() remove_container()

View File

@ -565,6 +565,8 @@ class Project(object):
renew_anonymous_volumes=False, renew_anonymous_volumes=False,
silent=False, silent=False,
cli=False, cli=False,
one_off=False,
override_options=None,
): ):
if cli: if cli:
@ -584,7 +586,11 @@ class Project(object):
for svc in services: for svc in services:
svc.ensure_image_exists(do_build=do_build, silent=silent, cli=cli) svc.ensure_image_exists(do_build=do_build, silent=silent, cli=cli)
plans = self._get_convergence_plans( plans = self._get_convergence_plans(
services, strategy, always_recreate_deps=always_recreate_deps) services,
strategy,
always_recreate_deps=always_recreate_deps,
one_off=service_names if one_off else [],
)
def do(service): def do(service):
@ -597,6 +603,7 @@ class Project(object):
start=start, start=start,
reset_container_image=reset_container_image, reset_container_image=reset_container_image,
renew_anonymous_volumes=renew_anonymous_volumes, renew_anonymous_volumes=renew_anonymous_volumes,
override_options=override_options,
) )
def get_deps(service): def get_deps(service):
@ -628,7 +635,7 @@ class Project(object):
self.networks.initialize() self.networks.initialize()
self.volumes.initialize() self.volumes.initialize()
def _get_convergence_plans(self, services, strategy, always_recreate_deps=False): def _get_convergence_plans(self, services, strategy, always_recreate_deps=False, one_off=None):
plans = {} plans = {}
for service in services: for service in services:
@ -638,6 +645,7 @@ class Project(object):
if name in plans and if name in plans and
plans[name].action in ('recreate', 'create') plans[name].action in ('recreate', 'create')
] ]
is_one_off = one_off and service.name in one_off
if updated_dependencies and strategy.allows_recreate: if updated_dependencies and strategy.allows_recreate:
log.debug('%s has upstream changes (%s)', log.debug('%s has upstream changes (%s)',
@ -649,11 +657,11 @@ class Project(object):
container_has_links = any(c.get('HostConfig.Links') for c in service.containers()) container_has_links = any(c.get('HostConfig.Links') for c in service.containers())
should_recreate_for_links = service_has_links ^ container_has_links should_recreate_for_links = service_has_links ^ container_has_links
if always_recreate_deps or containers_stopped or should_recreate_for_links: if always_recreate_deps or containers_stopped or should_recreate_for_links:
plan = service.convergence_plan(ConvergenceStrategy.always) plan = service.convergence_plan(ConvergenceStrategy.always, is_one_off)
else: else:
plan = service.convergence_plan(strategy) plan = service.convergence_plan(strategy, is_one_off)
else: else:
plan = service.convergence_plan(strategy) plan = service.convergence_plan(strategy, is_one_off)
plans[service.name] = plan plans[service.name] = plan

View File

@ -388,9 +388,12 @@ class Service(object):
platform = self.default_platform platform = self.default_platform
return platform return platform
def convergence_plan(self, strategy=ConvergenceStrategy.changed): def convergence_plan(self, strategy=ConvergenceStrategy.changed, one_off=False):
containers = self.containers(stopped=True) containers = self.containers(stopped=True)
if one_off:
return ConvergencePlan('one_off', [])
if not containers: if not containers:
return ConvergencePlan('create', []) return ConvergencePlan('create', [])
@ -439,25 +442,37 @@ class Service(object):
return has_diverged return has_diverged
def _execute_convergence_create(self, scale, detached, start): def _execute_convergence_create(self, scale, detached, start, one_off=False, override_options=None):
i = self._next_container_number() i = self._next_container_number()
def create_and_start(service, n): def create_and_start(service, n):
if one_off:
container = service.create_container(one_off=True, quiet=True, **override_options)
else:
container = service.create_container(number=n, quiet=True) container = service.create_container(number=n, quiet=True)
if not detached: if not detached:
container.attach_log_stream() container.attach_log_stream()
if start: if start and not one_off:
self.start_container(container) self.start_container(container)
return container return container
def get_name(service_name):
if one_off:
return "_".join([
service_name.project,
service_name.service,
"run",
])
return self.get_container_name(service_name.service, service_name.number)
containers, errors = parallel_execute( containers, errors = parallel_execute(
[ [
ServiceName(self.project, self.name, index) ServiceName(self.project, self.name, index)
for index in range(i, i + scale) for index in range(i, i + scale)
], ],
lambda service_name: create_and_start(self, service_name.number), lambda service_name: create_and_start(self, service_name.number),
lambda service_name: self.get_container_name(service_name.service, service_name.number), get_name,
"Creating" "Creating"
) )
for error in errors.values(): for error in errors.values():
@ -528,16 +543,20 @@ class Service(object):
def execute_convergence_plan(self, plan, timeout=None, detached=False, def execute_convergence_plan(self, plan, timeout=None, detached=False,
start=True, scale_override=None, start=True, scale_override=None,
rescale=True, reset_container_image=False, rescale=True, reset_container_image=False,
renew_anonymous_volumes=False): renew_anonymous_volumes=False, override_options=None):
(action, containers) = plan (action, containers) = plan
scale = scale_override if scale_override is not None else self.scale_num scale = scale_override if scale_override is not None else self.scale_num
containers = sorted(containers, key=attrgetter('number')) containers = sorted(containers, key=attrgetter('number'))
self.show_scale_warnings(scale) self.show_scale_warnings(scale)
if action == 'create': if action in ['create', 'one_off']:
return self._execute_convergence_create( return self._execute_convergence_create(
scale, detached, start scale,
detached,
start,
one_off=(action == 'one_off'),
override_options=override_options
) )
# The create action needs always needs an initial scale, but otherwise, # The create action needs always needs an initial scale, but otherwise,

View File

@ -1780,6 +1780,14 @@ services:
assert len(db.containers()) == 1 assert len(db.containers()) == 1
assert len(console.containers()) == 0 assert len(console.containers()) == 0
def test_run_service_with_unhealthy_dependencies(self):
self.base_dir = 'tests/fixtures/v2-unhealthy-dependencies'
result = self.dispatch(['run', 'web', '/bin/true'], returncode=1)
assert re.search(
re.compile('for web .*is unhealthy.*', re.MULTILINE),
result.stderr
)
def test_run_service_with_scaled_dependencies(self): def test_run_service_with_scaled_dependencies(self):
self.base_dir = 'tests/fixtures/v2-dependencies' self.base_dir = 'tests/fixtures/v2-dependencies'
self.dispatch(['up', '-d', '--scale', 'db=2', '--scale', 'console=0']) self.dispatch(['up', '-d', '--scale', 'db=2', '--scale', 'console=0'])

View File

@ -0,0 +1,19 @@
version: "2.1"
services:
db:
image: busybox:1.31.0-uclibc
command: top
healthcheck:
test: exit 1
interval: 1s
timeout: 1s
retries: 1
web:
image: busybox:1.31.0-uclibc
command: top
depends_on:
db:
condition: service_healthy
console:
image: busybox:1.31.0-uclibc
command: top

View File

@ -18,6 +18,8 @@ from compose.cli.docopt_command import NoSuchCommand
from compose.cli.errors import UserError from compose.cli.errors import UserError
from compose.cli.main import TopLevelCommand from compose.cli.main import TopLevelCommand
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
from compose.const import LABEL_SERVICE
from compose.container import Container
from compose.project import Project from compose.project import Project
@ -94,12 +96,26 @@ class CLITestCase(unittest.TestCase):
@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty")
@mock.patch('compose.cli.main.RunOperation', autospec=True) @mock.patch('compose.cli.main.RunOperation', autospec=True)
@mock.patch('compose.cli.main.PseudoTerminal', autospec=True) @mock.patch('compose.cli.main.PseudoTerminal', autospec=True)
@mock.patch('compose.service.Container.create')
@mock.patch.dict(os.environ) @mock.patch.dict(os.environ)
def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): def test_run_interactive_passes_logs_false(
self,
mock_container_create,
mock_pseudo_terminal,
mock_run_operation,
):
os.environ['COMPOSE_INTERACTIVE_NO_CLI'] = 'true' os.environ['COMPOSE_INTERACTIVE_NO_CLI'] = 'true'
mock_client = mock.create_autospec(docker.APIClient) mock_client = mock.create_autospec(docker.APIClient)
mock_client.api_version = DEFAULT_DOCKER_API_VERSION mock_client.api_version = DEFAULT_DOCKER_API_VERSION
mock_client._general_configs = {} mock_client._general_configs = {}
mock_container_create.return_value = Container(mock_client, {
'Id': '37b35e0ba80d91009d37e16f249b32b84f72bda269985578ed6c75a0a13fcaa8',
'Config': {
'Labels': {
LABEL_SERVICE: 'service',
}
},
}, has_been_inspected=True)
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
client=mock_client, client=mock_client,
@ -132,10 +148,20 @@ class CLITestCase(unittest.TestCase):
_, _, call_kwargs = mock_run_operation.mock_calls[0] _, _, call_kwargs = mock_run_operation.mock_calls[0]
assert call_kwargs['logs'] is False assert call_kwargs['logs'] is False
def test_run_service_with_restart_always(self): @mock.patch('compose.service.Container.create')
def test_run_service_with_restart_always(self, mock_container_create):
mock_client = mock.create_autospec(docker.APIClient) mock_client = mock.create_autospec(docker.APIClient)
mock_client.api_version = DEFAULT_DOCKER_API_VERSION mock_client.api_version = DEFAULT_DOCKER_API_VERSION
mock_client._general_configs = {} mock_client._general_configs = {}
mock_container_create.return_value = Container(mock_client, {
'Id': '37b35e0ba80d91009d37e16f249b32b84f72bda269985578ed6c75a0a13fcaa8',
'Name': 'composetest_service_37b35',
'Config': {
'Labels': {
LABEL_SERVICE: 'service',
}
},
}, has_been_inspected=True)
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',