docker-cmd hook
This hook takes the same format as the docker-compose hook, but makes calls directly to the docker command rather than calling docker-compose. This hook currently supports the docker-compose v1 format, but in the future will support other formats such as the kubernetes pod format. TripleO will adopt this hook and will transition to using the pod format when this hook supports it. Co-Authored-By: Ian Main <imain@redhat.com> Change-Id: I699107c3df64723a945c5d5ac82ae3a48b76700e
This commit is contained in:
parent
40a4ed0841
commit
2baf44a32c
@ -0,0 +1,9 @@
|
||||
A hook which uses the `docker` command to deploy containers.
|
||||
|
||||
The hook currently supports specifying containers in the `docker-compose v1
|
||||
format <https://docs.docker.com/compose/compose-file/#/version-1>`_. The
|
||||
intention is for this hook to also support the kubernetes pod format.
|
||||
|
||||
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.
|
@ -0,0 +1,2 @@
|
||||
os-apply-config
|
||||
os-refresh-config
|
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
|
||||
SCRIPTDIR=$(dirname $0)
|
||||
|
||||
install -D -g root -o root -m 0755 ${SCRIPTDIR}/hook-docker-cmd.py /var/lib/heat-config/hooks/docker-cmd
|
138
hot/software-config/elements/heat-config-docker-cmd/install.d/hook-docker-cmd.py
Executable file
138
hot/software-config/elements/heat-config-docker-cmd/install.d/hook-docker-cmd.py
Executable file
@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
|
||||
DOCKER_CMD = os.environ.get('HEAT_DOCKER_CMD', 'docker')
|
||||
|
||||
|
||||
log = None
|
||||
|
||||
|
||||
def build_response(deploy_stdout, deploy_stderr, deploy_status_code):
|
||||
return {
|
||||
'deploy_stdout': deploy_stdout,
|
||||
'deploy_stderr': deploy_stderr,
|
||||
'deploy_status_code': deploy_status_code,
|
||||
}
|
||||
|
||||
|
||||
def docker_arg_map(key, value):
|
||||
value = str(value).encode('ascii', 'ignore')
|
||||
return {
|
||||
'container_step_config': None,
|
||||
'environment': "--env=%s" % value,
|
||||
'image': value,
|
||||
'net': "--net=%s" % value,
|
||||
'pid': "--pid=%s" % value,
|
||||
'privileged': "--privileged=%s" % 'true' if value else 'false',
|
||||
'restart': "--restart=%s" % value,
|
||||
'user': "--user=%s" % value,
|
||||
'volumes': "--volume=%s" % value,
|
||||
'volumes_from': "--volumes-from=%s" % value,
|
||||
}.get(key, None)
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
global log
|
||||
log = logging.getLogger('heat-config')
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
'[%(asctime)s] (%(name)s) [%(levelname)s] %(message)s'))
|
||||
log.addHandler(handler)
|
||||
log.setLevel('DEBUG')
|
||||
|
||||
c = json.load(sys.stdin)
|
||||
|
||||
input_values = dict((i['name'], i['value']) for i in c.get('inputs', {}))
|
||||
|
||||
if input_values.get('deploy_action') == 'DELETE':
|
||||
json.dump(build_response(
|
||||
'', '', 0), sys.stdout)
|
||||
return
|
||||
|
||||
config = c.get('config', '')
|
||||
if not config:
|
||||
log.debug("No 'config' input found, nothing to do.")
|
||||
json.dump(build_response(
|
||||
'', '', 0), sys.stdout)
|
||||
return
|
||||
|
||||
stdout = []
|
||||
stderr = []
|
||||
deploy_status_code = 0
|
||||
|
||||
# convert config to dict
|
||||
if not isinstance(config, dict):
|
||||
config = yaml.safe_load(config)
|
||||
|
||||
for container in config:
|
||||
container_name = '%s__%s' % (c['name'], container)
|
||||
cmd = [
|
||||
DOCKER_CMD,
|
||||
'run',
|
||||
'--detach=true',
|
||||
'--name',
|
||||
container_name.encode('ascii', 'ignore'),
|
||||
]
|
||||
image_name = ''
|
||||
for key in sorted(config[container]):
|
||||
# These ones contain a list of values
|
||||
if key in ['environment', 'volumes', 'volumes_from']:
|
||||
for value in config[container][key]:
|
||||
# Somehow the lists get empty values sometimes
|
||||
if type(value) is unicode and not value.strip():
|
||||
continue
|
||||
cmd.append(docker_arg_map(key, value))
|
||||
elif key == 'image':
|
||||
image_name = config[container][key].encode('ascii', 'ignore')
|
||||
else:
|
||||
arg = docker_arg_map(key, config[container][key])
|
||||
if arg:
|
||||
cmd.append(arg)
|
||||
|
||||
# Image name must come last.
|
||||
cmd.append(image_name)
|
||||
|
||||
log.debug(' '.join(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)
|
||||
if cmd_stdout:
|
||||
stdout.append(cmd_stdout)
|
||||
if cmd_stderr:
|
||||
stderr.append(cmd_stderr)
|
||||
|
||||
if subproc.returncode:
|
||||
log.error("Error running %s. [%s]\n" % (cmd, subproc.returncode))
|
||||
else:
|
||||
log.debug('Completed %s' % cmd)
|
||||
|
||||
if subproc.returncode != 0:
|
||||
deploy_status_code = subproc.returncode
|
||||
|
||||
json.dump(build_response(
|
||||
'\n'.join(stdout), '\n'.join(stderr), deploy_status_code), sys.stdout)
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
CONF_FILE = os.environ.get('HEAT_SHELL_CONFIG',
|
||||
'/var/run/heat-config/heat-config')
|
||||
|
||||
WORKING_DIR = os.environ.get(
|
||||
'HEAT_DOCKER_CMD_WORKING',
|
||||
'/var/lib/heat-config/heat-config-docker-cmd')
|
||||
|
||||
DOCKER_CMD = os.environ.get('HEAT_DOCKER_CMD', 'docker')
|
||||
|
||||
|
||||
log = None
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
global log
|
||||
log = logging.getLogger('heat-config')
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
'[%(asctime)s] (%(name)s) [%(levelname)s] %(message)s'))
|
||||
log.addHandler(handler)
|
||||
log.setLevel('DEBUG')
|
||||
|
||||
if not os.path.exists(CONF_FILE):
|
||||
log.error('No config file %s' % CONF_FILE)
|
||||
return 1
|
||||
|
||||
if not os.path.isdir(WORKING_DIR):
|
||||
os.makedirs(WORKING_DIR, 0o700)
|
||||
|
||||
try:
|
||||
configs = json.load(open(CONF_FILE))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
cmd_configs = list(build_configs(configs))
|
||||
try:
|
||||
delete_missing_projects(cmd_configs)
|
||||
for c in cmd_configs:
|
||||
delete_changed_project(c)
|
||||
write_project(c)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
|
||||
def build_configs(configs):
|
||||
for c in configs:
|
||||
if c['group'] != 'docker-cmd':
|
||||
continue
|
||||
if not isinstance(c['config'], dict):
|
||||
# convert config to dict
|
||||
c['config'] = yaml.safe_load(c['config'])
|
||||
yield c
|
||||
|
||||
|
||||
def current_projects():
|
||||
for proj_file in os.listdir(WORKING_DIR):
|
||||
if proj_file.endswith('.json'):
|
||||
proj = proj_file[:-5]
|
||||
yield proj
|
||||
|
||||
|
||||
def remove_project(proj):
|
||||
proj_file = os.path.join(WORKING_DIR, '%s.json' % proj)
|
||||
with open(proj_file, 'r') as f:
|
||||
proj_data = json.load(f)
|
||||
for name in extract_container_names(proj, proj_data):
|
||||
remove_container(name)
|
||||
os.remove(proj_file)
|
||||
|
||||
|
||||
def remove_container(name):
|
||||
cmd = [DOCKER_CMD, 'rm', '-f', name]
|
||||
log.debug(' '.join(cmd))
|
||||
subproc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
stdout, stderr = subproc.communicate()
|
||||
log.info(stdout)
|
||||
log.debug(stderr)
|
||||
|
||||
|
||||
def delete_missing_projects(configs):
|
||||
config_names = [c['name'] for c in configs]
|
||||
for proj in current_projects():
|
||||
if proj not in config_names:
|
||||
log.debug('%s no longer exists, deleting containers' % proj)
|
||||
remove_project(proj)
|
||||
|
||||
|
||||
def extract_container_names(proj, proj_data):
|
||||
# For now, assume a docker-compose v1 format where the
|
||||
# root keys are service names
|
||||
for name in proj_data:
|
||||
yield '%s__%s' % (proj, name)
|
||||
|
||||
|
||||
def delete_changed_project(c):
|
||||
proj = c['name']
|
||||
proj_file = os.path.join(WORKING_DIR, '%s.json' % proj)
|
||||
proj_data = c.get('config', {})
|
||||
if os.path.isfile(proj_file):
|
||||
with open(proj_file, 'r') as f:
|
||||
prev_proj_data = json.load(f)
|
||||
if proj_data != prev_proj_data:
|
||||
log.debug('%s has changed, deleting containers' % proj)
|
||||
remove_project(proj)
|
||||
|
||||
|
||||
def write_project(c):
|
||||
proj = c['name']
|
||||
proj_file = os.path.join(WORKING_DIR, '%s.json' % proj)
|
||||
proj_data = c.get('config', {})
|
||||
|
||||
with os.fdopen(os.open(
|
||||
proj_file, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600),
|
||||
'w') as f:
|
||||
json.dump(proj_data, f, indent=2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
@ -28,7 +28,16 @@ import sys
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
with open(os.environ.get('TEST_STATE_PATH'), 'w') as f:
|
||||
|
||||
state_path = os.environ.get('TEST_STATE_PATH')
|
||||
|
||||
# handle multiple invocations by writing to numbered state path files
|
||||
suffix = 0
|
||||
while os.path.isfile(state_path):
|
||||
suffix += 1
|
||||
state_path = '%s_%s' % (os.environ.get('TEST_STATE_PATH'), suffix)
|
||||
|
||||
with open(state_path, 'w') as f:
|
||||
json.dump({'env': dict(os.environ), 'args': argv}, f)
|
||||
|
||||
if 'TEST_RESPONSE' not in os.environ:
|
||||
|
267
tests/software_config/test_hook_docker_cmd.py
Normal file
267
tests/software_config/test_hook_docker_cmd.py
Normal file
@ -0,0 +1,267 @@
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import fixtures
|
||||
from testtools import matchers
|
||||
|
||||
from tests.software_config import common
|
||||
|
||||
|
||||
class HookDockerComposeTest(common.RunScriptTest):
|
||||
data = {
|
||||
"name": "abcdef001",
|
||||
"group": "docker-cmd",
|
||||
"config": {
|
||||
"web": {
|
||||
"name": "x",
|
||||
"image": "xxx"
|
||||
},
|
||||
"db": {
|
||||
"name": "y",
|
||||
"image": "xxx",
|
||||
"net": "host",
|
||||
"restart": "always",
|
||||
"privileged": True,
|
||||
"user": "root",
|
||||
"volumes": [
|
||||
"/run:/run",
|
||||
"db:/var/lib/db"
|
||||
],
|
||||
"environment": [
|
||||
"KOLLA_CONFIG_STRATEGY=COPY_ALWAYS",
|
||||
"FOO=BAR"
|
||||
]
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(HookDockerComposeTest, self).setUp()
|
||||
self.hook_path = self.relative_path(
|
||||
__file__,
|
||||
'../..',
|
||||
'hot/software-config/elements',
|
||||
'heat-config-docker-cmd/install.d/hook-docker-cmd.py')
|
||||
|
||||
self.cleanup_path = self.relative_path(
|
||||
__file__,
|
||||
'../..',
|
||||
'hot/software-config/elements/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_WORKING': self.working_dir.join(),
|
||||
'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({
|
||||
'stdout': '',
|
||||
'stderr': 'Creating abcdef001_db_1...'
|
||||
})
|
||||
})
|
||||
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 abcdef001_db_1...\n'
|
||||
'Creating abcdef001_db_1...',
|
||||
'deploy_status_code': 0
|
||||
}, json.loads(stdout))
|
||||
|
||||
state_0 = self.json_from_file(self.test_state_path)
|
||||
state_1 = self.json_from_file('%s_1' % self.test_state_path)
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'run',
|
||||
'--detach=true',
|
||||
'--name',
|
||||
'abcdef001__web',
|
||||
'xxx'
|
||||
], state_0['args'])
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'run',
|
||||
'--detach=true',
|
||||
'--name',
|
||||
'abcdef001__db',
|
||||
'--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',
|
||||
'xxx'
|
||||
], state_1['args'])
|
||||
|
||||
def test_hook_failed(self):
|
||||
|
||||
self.env.update({
|
||||
'TEST_RESPONSE': json.dumps({
|
||||
'stdout': '',
|
||||
'stderr': 'Error: image library/xxx:latest not found',
|
||||
'returncode': 1
|
||||
})
|
||||
})
|
||||
returncode, stdout, stderr = self.run_cmd(
|
||||
[self.hook_path], self.env, json.dumps(self.data))
|
||||
|
||||
self.assertEqual({
|
||||
'deploy_stdout': '',
|
||||
'deploy_stderr': 'Error: image library/xxx:latest not found\n'
|
||||
'Error: image library/xxx:latest not found',
|
||||
'deploy_status_code': 1
|
||||
}, json.loads(stdout))
|
||||
|
||||
state_0 = self.json_from_file(self.test_state_path)
|
||||
state_1 = self.json_from_file('%s_1' % self.test_state_path)
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'run',
|
||||
'--detach=true',
|
||||
'--name',
|
||||
'abcdef001__web',
|
||||
'xxx'
|
||||
], state_0['args'])
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'run',
|
||||
'--detach=true',
|
||||
'--name',
|
||||
'abcdef001__db',
|
||||
'--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',
|
||||
'xxx'
|
||||
], state_1['args'])
|
||||
|
||||
def test_cleanup_deleted(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(json.dumps([self.data]))
|
||||
f.flush()
|
||||
self.env['HEAT_SHELL_CONFIG'] = f.name
|
||||
|
||||
returncode, stdout, stderr = self.run_cmd(
|
||||
[self.cleanup_path], self.env)
|
||||
|
||||
# on the first run, abcdef001.json is written out, no docker calls made
|
||||
configs_path = os.path.join(self.env['HEAT_DOCKER_CMD_WORKING'],
|
||||
'abcdef001.json')
|
||||
self.assertThat(configs_path, matchers.FileExists())
|
||||
self.assertThat(self.test_state_path,
|
||||
matchers.Not(matchers.FileExists()))
|
||||
|
||||
# run again with empty config data
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(json.dumps([]))
|
||||
f.flush()
|
||||
self.env['HEAT_SHELL_CONFIG'] = f.name
|
||||
|
||||
returncode, stdout, stderr = self.run_cmd(
|
||||
[self.cleanup_path], self.env)
|
||||
|
||||
# on the second run, abcdef001.json is deleted, docker rm is run on
|
||||
# both containers
|
||||
configs_path = os.path.join(self.env['HEAT_DOCKER_CMD_WORKING'],
|
||||
'abcdef001.json')
|
||||
self.assertThat(configs_path,
|
||||
matchers.Not(matchers.FileExists()))
|
||||
state_0 = self.json_from_file(self.test_state_path)
|
||||
state_1 = self.json_from_file('%s_1' % self.test_state_path)
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'rm',
|
||||
'-f',
|
||||
'abcdef001__web',
|
||||
], state_0['args'])
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'rm',
|
||||
'-f',
|
||||
'abcdef001__db',
|
||||
], state_1['args'])
|
||||
|
||||
def test_cleanup_changed(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(json.dumps([self.data]))
|
||||
f.flush()
|
||||
self.env['HEAT_SHELL_CONFIG'] = f.name
|
||||
|
||||
returncode, stdout, stderr = self.run_cmd(
|
||||
[self.cleanup_path], self.env)
|
||||
|
||||
# on the first run, abcdef001.json is written out, no docker calls made
|
||||
configs_path = os.path.join(self.env['HEAT_DOCKER_CMD_WORKING'],
|
||||
'abcdef001.json')
|
||||
self.assertThat(configs_path, matchers.FileExists())
|
||||
self.assertThat(self.test_state_path,
|
||||
matchers.Not(matchers.FileExists()))
|
||||
|
||||
# run again with changed config data
|
||||
new_data = copy.deepcopy(self.data)
|
||||
new_data['config']['web']['image'] = 'yyy'
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
f.write(json.dumps([new_data]))
|
||||
f.flush()
|
||||
self.env['HEAT_SHELL_CONFIG'] = f.name
|
||||
|
||||
returncode, stdout, stderr = self.run_cmd(
|
||||
[self.cleanup_path], self.env)
|
||||
|
||||
# on the second run, abcdef001.json is written with the new data,
|
||||
# docker rm is run on both containers
|
||||
configs_path = os.path.join(self.env['HEAT_DOCKER_CMD_WORKING'],
|
||||
'abcdef001.json')
|
||||
self.assertThat(configs_path, matchers.FileExists())
|
||||
state_0 = self.json_from_file(self.test_state_path)
|
||||
state_1 = self.json_from_file('%s_1' % self.test_state_path)
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'rm',
|
||||
'-f',
|
||||
'abcdef001__web',
|
||||
], state_0['args'])
|
||||
self.assertEqual([
|
||||
self.fake_tool_path,
|
||||
'rm',
|
||||
'-f',
|
||||
'abcdef001__db',
|
||||
], state_1['args'])
|
Loading…
Reference in New Issue
Block a user