diff --git a/doc/source/install/hooks/docker-cmd.rst b/doc/source/install/hooks/docker-cmd.rst index ab0f8e4..b57dd98 100644 --- a/doc/source/install/hooks/docker-cmd.rst +++ b/doc/source/install/hooks/docker-cmd.rst @@ -2,7 +2,7 @@ docker-cmd ========== -A hook which uses the ``docker`` command via `paunch`_ to deploy containers. +A hook which uses the `docker` command to deploy containers. The hook currently supports specifying containers in the `docker-compose v1 format`_. The intention is for this hook to also support the kubernetes pod @@ -12,5 +12,4 @@ A dedicated os-refresh-config script will remove running containers if a deployment is removed or changed, then the docker-cmd hook will run any containers in new or updated deployments. -.. _paunch: https://docs.openstack.org/paunch/latest/ .. _docker-compose v1 format: https://docs.docker.com/compose/compose-file/#/version-1 diff --git a/heat-config-docker-cmd/install.d/hook-docker-cmd.py b/heat-config-docker-cmd/install.d/hook-docker-cmd.py index 189938c..4fb0c54 100755 --- a/heat-config-docker-cmd/install.d/hook-docker-cmd.py +++ b/heat-config-docker-cmd/install.d/hook-docker-cmd.py @@ -12,13 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. -import collections import json import logging import os +import random +import string +import subprocess import sys -import paunch import yaml DOCKER_CMD = os.environ.get('HEAT_DOCKER_CMD', 'docker') @@ -35,7 +36,138 @@ def build_response(deploy_stdout, deploy_stderr, deploy_status_code): } +def docker_run_args(cmd, container, config): + cconfig = config[container] + if cconfig.get('detach', True): + cmd.append('--detach=true') + if 'env_file' in cconfig: + if isinstance(cconfig['env_file'], list): + for f in cconfig.get('env_file', []): + if f: + cmd.append('--env-file=%s' % f) + else: + cmd.append('--env-file=%s' % cconfig['env_file']) + for v in cconfig.get('environment', []): + if v: + cmd.append('--env=%s' % v) + if 'net' in cconfig: + cmd.append('--net=%s' % cconfig['net']) + if 'pid' in cconfig: + cmd.append('--pid=%s' % cconfig['pid']) + if 'privileged' in cconfig: + cmd.append('--privileged=%s' % str(cconfig['privileged']).lower()) + if 'restart' in cconfig: + cmd.append('--restart=%s' % cconfig['restart']) + if 'user' in cconfig: + cmd.append('--user=%s' % cconfig['user']) + for v in cconfig.get('volumes', []): + if v: + cmd.append('--volume=%s' % v) + for v in cconfig.get('volumes_from', []): + if v: + cmd.append('--volumes_from=%s' % v) + + cmd.append(cconfig.get('image', '')) + cmd.extend(command_argument(cmd, cconfig.get('command'))) + + +def docker_exec_args(cmd, container, config, cid): + cconfig = config[container] + if 'privileged' in cconfig: + cmd.append('--privileged=%s' % str(cconfig['privileged']).lower()) + if 'user' in cconfig: + cmd.append('--user=%s' % cconfig['user']) + command = command_argument(cmd, cconfig.get('command')) + # for exec, the first argument is the container name, + # make sure the correct one is used + command[0] = discover_container_name(command[0], cid) + cmd.extend(command) + + +def command_argument(cmd, command): + if not command: + return [] + if not isinstance(command, list): + return command.split() + return command + + +def execute(cmd): + log.debug("execute command: %s" % cmd) + subproc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + cmd_stdout, cmd_stderr = subproc.communicate() + log.debug(cmd_stdout) + log.debug(cmd_stderr) + return cmd_stdout, cmd_stderr, subproc.returncode + + +def label_arguments(cmd, container, cid, iv): + cmd.extend([ + '--label', + 'deploy_stack_id=%s' % iv.get('deploy_stack_id'), + '--label', + 'deploy_resource_name=%s' % iv.get('deploy_resource_name'), + '--label', + 'config_id=%s' % cid, + '--label', + 'container_name=%s' % container, + '--label', + 'managed_by=docker-cmd' + ]) + + +def inspect(container, format=None): + cmd = [DOCKER_CMD, 'inspect'] + if format: + cmd.append('--format') + cmd.append(format) + cmd.append(container) + (cmd_stdout, cmd_stderr, returncode) = execute(cmd) + if returncode != 0: + return + try: + if format: + return cmd_stdout + else: + return json.loads(cmd_stdout)[0] + except Exception as e: + log.error('Problem parsing docker inspect: %s' % e) + + +def unique_container_name(container): + container_name = container + while inspect(container_name, format='exists'): + suffix = ''.join(random.choice( + string.ascii_lowercase + string.digits) for i in range(8)) + container_name = '%s-%s' % (container, suffix) + return container_name + + +def discover_container_name(container, cid): + cmd = [ + DOCKER_CMD, + 'ps', + '-a', + '--filter', + 'label=container_name=%s' % container, + '--filter', + 'label=config_id=%s' % cid, + '--format', + '{{.Names}}' + ] + (cmd_stdout, cmd_stderr, returncode) = execute(cmd) + if returncode != 0: + return container + names = cmd_stdout.split() + if names: + return names[0] + return container + + def main(argv=sys.argv, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr): + cmd_stderrs = [] + cmd_stdouts = [] global log log = logging.getLogger('heat-config') handler = logging.StreamHandler(stderr) @@ -68,21 +200,43 @@ def main(argv=sys.argv, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr): if not isinstance(config, dict): config = yaml.safe_load(config) - labels = collections.OrderedDict() - labels['deploy_stack_id'] = input_values.get('deploy_stack_id') - labels['deploy_resource_name'] = input_values.get('deploy_resource_name') - apply_stdout, apply_stderr, deploy_status_code = paunch.apply( - cid, - config, - 'docker-cmd', - labels, - DOCKER_CMD - ) + def key_fltr(key): + return config[key].get('start_order', 0) + for container in sorted(config, key=key_fltr): + log.debug("Running container: %s" % container) + action = config[container].get('action', 'run') + exit_codes = config[container].get('exit_codes', [0]) - json.dump(build_response( - '\n'.join(apply_stdout), - '\n'.join(apply_stderr), - deploy_status_code), stdout) + if action == 'run': + cmd = [ + DOCKER_CMD, + 'run', + '--name', + unique_container_name(container) + ] + label_arguments(cmd, container, cid, input_values) + docker_run_args(cmd, container, config) + elif action == 'exec': + cmd = [DOCKER_CMD, 'exec'] + docker_exec_args(cmd, container, config, cid) + + (cmd_stdout, cmd_stderr, returncode) = execute(cmd) + if cmd_stdout: + out_str = cmd_stdout.decode('utf-8') + stdout.write(out_str) + cmd_stdouts.append(out_str) + if cmd_stderr: + err_str = cmd_stderr.decode('utf-8') + stderr.write(err_str) + cmd_stderrs.append(err_str) + + if returncode not in exit_codes: + log.error("Error running %s. [%s]\n" % (cmd, returncode)) + deploy_status_code = returncode + else: + log.debug('Completed %s' % cmd) + json.dump(build_response('\n'.join(cmd_stdouts), '\n'.join(cmd_stderrs), + deploy_status_code), sys.stdout) if __name__ == '__main__': diff --git a/heat-config-docker-cmd/os-refresh-config/configure.d/50-heat-config-docker-cmd b/heat-config-docker-cmd/os-refresh-config/configure.d/50-heat-config-docker-cmd index 86b7d64..fcb5e05 100755 --- a/heat-config-docker-cmd/os-refresh-config/configure.d/50-heat-config-docker-cmd +++ b/heat-config-docker-cmd/os-refresh-config/configure.d/50-heat-config-docker-cmd @@ -17,7 +17,7 @@ import logging import os import sys -import paunch +import subprocess CONF_FILE = os.environ.get('HEAT_SHELL_CONFIG', '/var/run/heat-config/heat-config') @@ -51,11 +51,102 @@ def main(argv=sys.argv): cmd_config_ids = [c['id'] for c in configs if c['group'] == 'docker-cmd'] - paunch.cleanup( - cmd_config_ids, - 'docker-cmd', - DOCKER_CMD - ) + try: + delete_missing_configs(cmd_config_ids) + except Exception as e: + log.exception(e) + try: + rename_containers() + except Exception as e: + log.exception(e) + + +def delete_missing_configs(config_ids): + for conf_id in current_config_ids(): + if type(conf_id) is bytes: + conf_id = conf_id.decode('utf-8') + if conf_id not in config_ids: + log.debug('%s no longer exists, deleting containers' % conf_id) + remove_containers(conf_id) + + +def execute(cmd): + log.debug("execute command: %s" % cmd) + subproc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + cmd_stdout, cmd_stderr = subproc.communicate() + return cmd_stdout, cmd_stderr, subproc.returncode + + +def current_config_ids(): + # List all config_id labels for containers managed by docker-cmd + cmd = [ + DOCKER_CMD, 'ps', '-a', + '--filter', 'label=managed_by=docker-cmd', + '--format', '{{.Label "config_id"}}' + ] + cmd_stdout, cmd_stderr, returncode = execute(cmd) + if returncode != 0: + return set() + return set(cmd_stdout.split()) + + +def remove_containers(conf_id): + cmd = [ + DOCKER_CMD, 'ps', '-q', '-a', + '--filter', 'label=managed_by=docker-cmd', + '--filter', 'label=config_id=%s' % conf_id + ] + cmd_stdout, cmd_stderr, returncode = execute(cmd) + if returncode == 0: + for container in cmd_stdout.split(): + remove_container(container) + + +def remove_container(container): + cmd = [DOCKER_CMD, 'rm', '-f', container] + cmd_stdout, cmd_stderr, returncode = execute(cmd) + if returncode != 0: + log.error('Error removing container: %s' % container) + log.error(cmd_stderr) + + +def rename_containers(): + # list every container name, and its container_name label + cmd = [ + DOCKER_CMD, 'ps', '-a', + '--format', '{{.Names}} {{.Label "container_name"}}' + ] + cmd_stdout, cmd_stderr, returncode = execute(cmd) + if returncode != 0: + return + + lines = cmd_stdout.split(b"\n") + current_containers = [] + need_renaming = {} + for line in lines: + entry = line.split() + if not entry: + continue + current_containers.append(entry[0]) + + # ignore if container_name label not set + if len(entry) < 2: + continue + + # ignore if desired name is already actual name + if entry[0] == entry[-1]: + continue + + need_renaming[entry[0]] = entry[-1] + + for current, desired in sorted(need_renaming.items()): + if desired in current_containers: + log.info('Cannot rename "%s" since "%s" still exists' % ( + current, desired)) + else: + cmd = [DOCKER_CMD, 'rename', current, desired] + execute(cmd) if __name__ == '__main__': diff --git a/lower-constraints.txt b/lower-constraints.txt index 0cb8cd6..ef5123a 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -37,7 +37,6 @@ oslo.config==5.2.0 oslo.i18n==3.15.3 oslo.serialization==2.18.0 oslo.utils==3.33.0 -paunch==4.0.0 pbr==2.0.0 positional==1.2.1 prettytable==0.7.2 diff --git a/releasenotes/notes/switch-docker-cmd-hook-to-docker-cli-5d8f6deee2125d65.yaml b/releasenotes/notes/switch-docker-cmd-hook-to-docker-cli-5d8f6deee2125d65.yaml new file mode 100644 index 0000000..1a1358f --- /dev/null +++ b/releasenotes/notes/switch-docker-cmd-hook-to-docker-cli-5d8f6deee2125d65.yaml @@ -0,0 +1,6 @@ +--- +upgrade: + - | + paunch is retired, hance we now switch docker-cmd hook to use docker + client. Aware that when upgrade to current version of heat-agents hooks, + the dependency for docker-cmd hook will change accordingly. diff --git a/test-requirements.txt b/test-requirements.txt index 59a930d..885b786 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -5,10 +5,9 @@ coverage!=4.4,>=4.0 # Apache-2.0 fixtures>=3.0.0 # Apache-2.0/BSD # Hacking already pins down pep8, pyflakes and flake8 hacking>=3.0.1,<3.1.0 # Apache-2.0 -paunch>=4.0.0 # Apache-2.0 requests>=2.14.2 # Apache-2.0 requests-mock>=1.1.0 # Apache-2.0 -salt>=2017.7.4 # Apache-2.0 +salt>=2017.7.4;python_version!='3.8' # Apache-2.0 testrepository>=0.0.18 # Apache-2.0/BSD testscenarios>=0.4 # Apache-2.0/BSD testtools>=2.2.0 # MIT diff --git a/tests/test_hook_docker_cmd.py b/tests/test_hook_docker_cmd.py index 4b11e28..e59555a 100644 --- a/tests/test_hook_docker_cmd.py +++ b/tests/test_hook_docker_cmd.py @@ -11,13 +11,14 @@ # License for the specific language governing permissions and limitations # under the License. +import copy import json +import os +import tempfile -from six.moves import cStringIO as StringIO -from unittest import mock +import fixtures from tests import common -from tests import hook_docker_cmd class HookDockerCmdTest(common.RunScriptTest): @@ -71,93 +72,751 @@ class HookDockerCmdTest(common.RunScriptTest): } } - @mock.patch('paunch.apply', autospec=True) - def test_hook(self, mock_apply): - mock_apply.return_value = (['it', 'done'], ['one', 'two', 'three'], 0) - stdin = StringIO(json.dumps(self.data)) - stdout = StringIO() - stderr = StringIO() - hook_docker_cmd.main( - ['/path/to/hook-docker-cmd'], stdin, stdout, stderr) - mock_apply.assert_called_once_with( - 'abc123', - self.data['config'], - 'docker-cmd', - { - 'deploy_stack_id': 'the_stack', - 'deploy_resource_name': 'the_deployment' - }, - 'docker' - ) - - resp = json.loads(stdout.getvalue()) - - self.assertEqual({ - 'deploy_status_code': 0, - 'deploy_stderr': 'one\ntwo\nthree', - 'deploy_stdout': 'it\ndone' - }, resp) - - @mock.patch('paunch.apply', autospec=True) - def test_missing_config(self, mock_apply): - data = { - "name": "abcdef001", - "group": "docker-cmd", - "id": "abc123", - } - stdin = StringIO(json.dumps(data)) - stdout = StringIO() - stderr = StringIO() - hook_docker_cmd.main( - ['/path/to/hook-docker-cmd'], stdin, stdout, stderr) - mock_apply.assert_not_called() - - resp = json.loads(stdout.getvalue()) - - self.assertEqual({ - 'deploy_status_code': 0, - 'deploy_stderr': '', - 'deploy_stdout': '' - }, resp) - - @mock.patch('paunch.apply', autospec=True) - def test_action_delete(self, mock_apply): - data = { - "name": "abcdef001", - "group": "docker-cmd", - "id": "abc123", - "inputs": [{ - "name": "deploy_action", - "value": "DELETE" - }, { - "name": "deploy_stack_id", - "value": "the_stack", - }, { - "name": "deploy_resource_name", - "value": "the_deployment", - }], - "config": { - "db": { - "name": "x", - "image": "xxx", - "privileged": False, - "environment": ["foo=bar"], - "env_file": "env.file", - "start_order": 0 - } + data_exit_code = { + "name": "abcdef001", + "group": "docker-cmd", + "id": "abc123", + "config": { + "web-ls": { + "action": "exec", + "command": ["web", "/bin/ls", "-l"], + "exit_codes": [0, 1] } } - stdin = StringIO(json.dumps(data)) - stdout = StringIO() - stderr = StringIO() - hook_docker_cmd.main( - ['/path/to/hook-docker-cmd'], stdin, stdout, stderr) - mock_apply.assert_not_called() + } - resp = json.loads(stdout.getvalue()) + def setUp(self): + super(HookDockerCmdTest, self).setUp() + self.hook_path = self.relative_path( + __file__, + '..', + 'heat-config-docker-cmd/install.d/hook-docker-cmd.py') + + self.cleanup_path = self.relative_path( + __file__, + '..', + 'heat-config-docker-cmd/', + 'os-refresh-config/configure.d/50-heat-config-docker-cmd') + + self.fake_tool_path = self.relative_path( + __file__, + 'config-tool-fake.py') + + self.working_dir = self.useFixture(fixtures.TempDir()) + self.outputs_dir = self.useFixture(fixtures.TempDir()) + self.test_state_path = self.outputs_dir.join('test_state.json') + + self.env = os.environ.copy() + self.env.update({ + 'HEAT_DOCKER_CMD': self.fake_tool_path, + 'TEST_STATE_PATH': self.test_state_path, + }) + + def test_hook(self): + + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + 'stderr': 'Error: No such image, container or task: db', + 'returncode': 1 + }, { + 'stdout': '', + 'stderr': 'Creating db...' + }, { + 'stderr': 'Error: No such image, container or task: web', + 'returncode': 1 + }, { + 'stdout': '', + 'stderr': 'Creating web...' + }, { + 'stdout': 'web', + }, { + + 'stdout': '', + 'stderr': 'one.txt\ntwo.txt\nthree.txt' + }]) + }) + returncode, stdout, stderr = self.run_cmd( + [self.hook_path], self.env, json.dumps(self.data)) + + self.assertEqual(0, returncode, stderr) self.assertEqual({ - 'deploy_status_code': 0, - 'deploy_stderr': '', - 'deploy_stdout': '' - }, resp) + 'deploy_stdout': '', + 'deploy_stderr': 'Creating db...\n' + 'Creating web...\n' + 'one.txt\ntwo.txt\nthree.txt', + 'deploy_status_code': 0 + }, json.loads(stdout)) + + state = list(self.json_from_files(self.test_state_path, 6)) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + 'db', + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'run', + '--name', + 'db', + '--label', + 'deploy_stack_id=the_stack', + '--label', + 'deploy_resource_name=the_deployment', + '--label', + 'config_id=abc123', + '--label', + 'container_name=db', + '--label', + 'managed_by=docker-cmd', + '--detach=true', + '--env-file=env.file', + '--env=foo=bar', + '--privileged=false', + 'xxx' + '' + ], state[1]['args']) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + 'web', + ], state[2]['args']) + self.assertEqual([ + self.fake_tool_path, + 'run', + '--name', + 'web', + '--label', + 'deploy_stack_id=the_stack', + '--label', + 'deploy_resource_name=the_deployment', + '--label', + 'config_id=abc123', + '--label', + 'container_name=web', + '--label', + 'managed_by=docker-cmd', + '--detach=true', + '--env-file=foo.env', + '--env-file=bar.conf', + '--env=KOLLA_CONFIG_STRATEGY=COPY_ALWAYS', + '--env=FOO=BAR', + '--net=host', + '--privileged=true', + '--restart=always', + '--user=root', + '--volume=/run:/run', + '--volume=db:/var/lib/db', + 'yyy', + '/bin/webserver', + 'start' + ], state[3]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=container_name=web', + '--filter', + 'label=config_id=abc123', + '--format', + '{{.Names}}', + ], state[4]['args']) + self.assertEqual([ + self.fake_tool_path, + 'exec', + 'web', + '/bin/ls', + '-l' + ], state[5]['args']) + + def test_hook_exit_codes(self): + + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + 'stdout': 'web', + }, { + 'stdout': '', + 'stderr': 'Warning: custom exit code', + 'returncode': 1 + }]) + }) + returncode, stdout, stderr = self.run_cmd( + [self.hook_path], self.env, json.dumps(self.data_exit_code)) + + self.assertEqual({ + 'deploy_stdout': '', + 'deploy_stderr': 'Warning: custom exit code', + 'deploy_status_code': 0 + }, json.loads(stdout)) + + state = list(self.json_from_files(self.test_state_path, 2)) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=container_name=web', + '--filter', + 'label=config_id=abc123', + '--format', + '{{.Names}}', + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'exec', + 'web', + '/bin/ls', + '-l' + ], state[1]['args']) + + def test_hook_failed(self): + + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + 'stderr': 'Error: No such image, container or task: db', + 'returncode': 1 + }, { + 'stdout': '', + 'stderr': 'Creating db...' + }, { + 'stderr': 'Error: No such image, container or task: web', + 'returncode': 1 + }, { + 'stdout': '', + 'stderr': 'Creating web...' + }, { + 'stdout': 'web', + }, { + 'stdout': '', + 'stderr': 'No such file or directory', + 'returncode': 2 + }]) + }) + returncode, stdout, stderr = self.run_cmd( + [self.hook_path], self.env, json.dumps(self.data)) + + self.assertEqual({ + 'deploy_stdout': '', + 'deploy_stderr': 'Creating db...\n' + 'Creating web...\n' + 'No such file or directory', + 'deploy_status_code': 2 + }, json.loads(stdout)) + + state = list(self.json_from_files(self.test_state_path, 6)) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + 'db', + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'run', + '--name', + 'db', + '--label', + 'deploy_stack_id=the_stack', + '--label', + 'deploy_resource_name=the_deployment', + '--label', + 'config_id=abc123', + '--label', + 'container_name=db', + '--label', + 'managed_by=docker-cmd', + '--detach=true', + '--env-file=env.file', + '--env=foo=bar', + '--privileged=false', + 'xxx' + ], state[1]['args']) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + 'web', + ], state[2]['args']) + self.assertEqual([ + self.fake_tool_path, + 'run', + '--name', + 'web', + '--label', + 'deploy_stack_id=the_stack', + '--label', + 'deploy_resource_name=the_deployment', + '--label', + 'config_id=abc123', + '--label', + 'container_name=web', + '--label', + 'managed_by=docker-cmd', + '--detach=true', + '--env-file=foo.env', + '--env-file=bar.conf', + '--env=KOLLA_CONFIG_STRATEGY=COPY_ALWAYS', + '--env=FOO=BAR', + '--net=host', + '--privileged=true', + '--restart=always', + '--user=root', + '--volume=/run:/run', + '--volume=db:/var/lib/db', + 'yyy', + '/bin/webserver', + 'start' + ], state[3]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=container_name=web', + '--filter', + 'label=config_id=abc123', + '--format', + '{{.Names}}', + ], state[4]['args']) + self.assertEqual([ + self.fake_tool_path, + 'exec', + 'web', + '/bin/ls', + '-l' + ], state[5]['args']) + + def test_hook_unique_names(self): + + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + 'stdout': 'exists\n', + 'returncode': 0 + }, { + 'stderr': 'Error: No such image, container or task: db-blah', + 'returncode': 1 + }, { + 'stdout': '', + 'stderr': 'Creating db...' + }, { + 'stdout': 'exists\n', + 'returncode': 0 + }, { + 'stderr': 'Error: No such image, container or task: web-blah', + 'returncode': 1 + }, { + 'stdout': '', + 'stderr': 'Creating web...' + }, { + 'stdout': 'web-asdf1234', + }, { + 'stdout': '', + 'stderr': 'one.txt\ntwo.txt\nthree.txt' + }]) + }) + returncode, stdout, stderr = self.run_cmd( + [self.hook_path], self.env, json.dumps(self.data)) + + self.assertEqual(0, returncode, stderr) + + self.assertEqual({ + 'deploy_stdout': '', + 'deploy_stderr': 'Creating db...\n' + 'Creating web...\n' + 'one.txt\ntwo.txt\nthree.txt', + 'deploy_status_code': 0 + }, json.loads(stdout)) + + state = list(self.json_from_files(self.test_state_path, 8)) + db_container_name = state[1]['args'][4] + web_container_name = state[4]['args'][4] + self.assertRegex(db_container_name, 'db-[0-9a-z]{8}') + self.assertRegex(web_container_name, 'web-[0-9a-z]{8}') + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + 'db', + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + db_container_name, + ], state[1]['args']) + self.assertEqual([ + self.fake_tool_path, + 'run', + '--name', + db_container_name, + '--label', + 'deploy_stack_id=the_stack', + '--label', + 'deploy_resource_name=the_deployment', + '--label', + 'config_id=abc123', + '--label', + 'container_name=db', + '--label', + 'managed_by=docker-cmd', + '--detach=true', + '--env-file=env.file', + '--env=foo=bar', + '--privileged=false', + 'xxx' + ], state[2]['args']) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + 'web', + ], state[3]['args']) + self.assertEqual([ + self.fake_tool_path, + 'inspect', + '--format', + 'exists', + web_container_name, + ], state[4]['args']) + self.assertEqual([ + self.fake_tool_path, + 'run', + '--name', + web_container_name, + '--label', + 'deploy_stack_id=the_stack', + '--label', + 'deploy_resource_name=the_deployment', + '--label', + 'config_id=abc123', + '--label', + 'container_name=web', + '--label', + 'managed_by=docker-cmd', + '--detach=true', + '--env-file=foo.env', + '--env-file=bar.conf', + '--env=KOLLA_CONFIG_STRATEGY=COPY_ALWAYS', + '--env=FOO=BAR', + '--net=host', + '--privileged=true', + '--restart=always', + '--user=root', + '--volume=/run:/run', + '--volume=db:/var/lib/db', + 'yyy', + '/bin/webserver', + 'start' + ], state[5]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=container_name=web', + '--filter', + 'label=config_id=abc123', + '--format', + '{{.Names}}', + ], state[6]['args']) + self.assertEqual([ + self.fake_tool_path, + 'exec', + 'web-asdf1234', + '/bin/ls', + '-l' + ], state[7]['args']) + + def test_cleanup_deleted(self): + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + # first run, no running containers + 'stdout': '\n' + }, { + # list name and container_name label for all containers + 'stdout': '\n' + }]) + }) + conf_dir = self.useFixture(fixtures.TempDir()).join() + with tempfile.NamedTemporaryFile(dir=conf_dir, delete=False) as f: + f.write(json.dumps([self.data]).encode('utf-8', 'replace')) + f.flush() + self.env['HEAT_SHELL_CONFIG'] = f.name + + returncode, stdout, stderr = self.run_cmd( + [self.cleanup_path], self.env) + + # on the first run, no docker rm calls made + state = list(self.json_from_files(self.test_state_path, 2)) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--format', + '{{.Label "config_id"}}' + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--format', + '{{.Names}} {{.Label "container_name"}}' + ], state[1]['args']) + + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + # list config_id labels, 3 containers same config + 'stdout': 'abc123\nabc123\nabc123\n' + }, { + # list containers with config_id + 'stdout': '111\n222\n333\n' + }, { + 'stdout': '111 deleted' + }, { + 'stdout': '222 deleted' + }, { + 'stdout': '333 deleted' + }, { + # list name and container_name label for all containers + 'stdout': '\n' + }]) + }) + + # run again with empty config data + with tempfile.NamedTemporaryFile(dir=conf_dir, delete=False) as f: + f.write(json.dumps([]).encode('utf-8', 'replace')) + f.flush() + self.env['HEAT_SHELL_CONFIG'] = f.name + + returncode, stdout, stderr = self.run_cmd( + [self.cleanup_path], self.env) + + # on the second run, abc123 is deleted, + # docker rm is run on all containers + state = list(self.json_from_files(self.test_state_path, 6)) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--format', + '{{.Label "config_id"}}' + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-q', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--filter', + 'label=config_id=abc123' + ], state[1]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rm', + '-f', + '111', + ], state[2]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rm', + '-f', + '222', + ], state[3]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rm', + '-f', + '333', + ], state[4]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--format', + '{{.Names}} {{.Label "container_name"}}' + ], state[5]['args']) + + def test_cleanup_changed(self): + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + # list config_id labels, 3 containers same config + 'stdout': 'abc123\nabc123\nabc123\n' + }, { + # list name and container_name label for all containers + 'stdout': '111 111\n' + '222 222\n' + '333\n' + }]) + }) + conf_dir = self.useFixture(fixtures.TempDir()).join() + with tempfile.NamedTemporaryFile(dir=conf_dir, delete=False) as f: + f.write(json.dumps([self.data]).encode('utf-8', 'replace')) + f.flush() + self.env['HEAT_SHELL_CONFIG'] = f.name + + returncode, stdout, stderr = self.run_cmd( + [self.cleanup_path], self.env) + + # on the first run, no docker rm calls made + state = list(self.json_from_files(self.test_state_path, 2)) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--format', + '{{.Label "config_id"}}' + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--format', + '{{.Names}} {{.Label "container_name"}}' + ], state[1]['args']) + + # run again with changed config data + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + # list config_id labels, 3 containers same config + 'stdout': 'abc123\nabc123\nabc123\n' + }, { + # list containers with config_id + 'stdout': '111\n222\n333\n' + }, { + 'stdout': '111 deleted' + }, { + 'stdout': '222 deleted' + }, { + 'stdout': '333 deleted' + }, { + # list name and container_name label for all containers + 'stdout': 'abc123 abc123\n' + }]) + }) + new_data = copy.deepcopy(self.data) + new_data['config']['web']['image'] = 'yyy' + new_data['id'] = 'def456' + with tempfile.NamedTemporaryFile(dir=conf_dir, delete=False) as f: + f.write(json.dumps([new_data]).encode('utf-8', 'replace')) + f.flush() + self.env['HEAT_SHELL_CONFIG'] = f.name + + returncode, stdout, stderr = self.run_cmd( + [self.cleanup_path], self.env) + + # on the second run, abc123 is deleted, + # docker rm is run on all containers + state = list(self.json_from_files(self.test_state_path, 6)) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--format', + '{{.Label "config_id"}}' + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-q', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--filter', + 'label=config_id=abc123' + ], state[1]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rm', + '-f', + '111', + ], state[2]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rm', + '-f', + '222', + ], state[3]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rm', + '-f', + '333', + ], state[4]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--format', + '{{.Names}} {{.Label "container_name"}}' + ], state[5]['args']) + + def test_cleanup_rename(self): + self.env.update({ + 'TEST_RESPONSE': json.dumps([{ + # list config_id labels, 3 containers same config + 'stdout': 'abc123\nabc123\nabc123\n' + }, { + # list name and container_name label for all containers + 'stdout': '111 111-s84nf83h\n' + '222 222\n' + '333 333-3nd83nfi\n' + }]) + }) + conf_dir = self.useFixture(fixtures.TempDir()).join() + with tempfile.NamedTemporaryFile(dir=conf_dir, delete=False) as f: + f.write(json.dumps([self.data]).encode('utf-8', 'replace')) + f.flush() + self.env['HEAT_SHELL_CONFIG'] = f.name + + returncode, stdout, stderr = self.run_cmd( + [self.cleanup_path], self.env) + + # on the first run, no docker rm calls made + state = list(self.json_from_files(self.test_state_path, 4)) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--filter', + 'label=managed_by=docker-cmd', + '--format', + '{{.Label "config_id"}}' + ], state[0]['args']) + self.assertEqual([ + self.fake_tool_path, + 'ps', + '-a', + '--format', + '{{.Names}} {{.Label "container_name"}}' + ], state[1]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rename', + '111', + '111-s84nf83h' + ], state[2]['args']) + self.assertEqual([ + self.fake_tool_path, + 'rename', + '333', + '333-3nd83nfi' + ], state[3]['args'])