Merge "Enable redis TLS proxy in HA deployments" into stable/pike
[apex-tripleo-heat-templates.git] / docker / docker-puppet.py
1 #!/usr/bin/env python
2 #
3 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
4 #    not use this file except in compliance with the License. You may obtain
5 #    a copy of the License at
6 #
7 #         http://www.apache.org/licenses/LICENSE-2.0
8 #
9 #    Unless required by applicable law or agreed to in writing, software
10 #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 #    License for the specific language governing permissions and limitations
13 #    under the License.
14
15 # Shell script tool to run puppet inside of the given docker container image.
16 # Uses the config file at /var/lib/docker-puppet/docker-puppet.json as a source for a JSON
17 # array of [config_volume, puppet_tags, manifest, config_image, [volumes]] settings
18 # that can be used to generate config files or run ad-hoc puppet modules
19 # inside of a container.
20
21 import glob
22 import json
23 import logging
24 import os
25 import sys
26 import subprocess
27 import sys
28 import tempfile
29 import time
30 import multiprocessing
31
32 logger = None
33
34 def get_logger():
35     global logger
36     if logger is None:
37         logger = logging.getLogger()
38         ch = logging.StreamHandler(sys.stdout)
39         if os.environ.get('DEBUG', False):
40             logger.setLevel(logging.DEBUG)
41             ch.setLevel(logging.DEBUG)
42         else:
43             logger.setLevel(logging.INFO)
44             ch.setLevel(logging.INFO)
45         formatter = logging.Formatter('%(asctime)s %(levelname)s: '
46                                       '%(process)s -- %(message)s')
47         ch.setFormatter(formatter)
48         logger.addHandler(ch)
49     return logger
50
51
52 # this is to match what we do in deployed-server
53 def short_hostname():
54     subproc = subprocess.Popen(['hostname', '-s'],
55                                stdout=subprocess.PIPE,
56                                stderr=subprocess.PIPE)
57     cmd_stdout, cmd_stderr = subproc.communicate()
58     return cmd_stdout.rstrip()
59
60
61 def pull_image(name):
62     log.info('Pulling image: %s' % name)
63     retval = -1
64     count = 0
65     while retval != 0:
66         count += 1
67         subproc = subprocess.Popen(['/usr/bin/docker', 'pull', name],
68                                    stdout=subprocess.PIPE,
69                                    stderr=subprocess.PIPE)
70
71         cmd_stdout, cmd_stderr = subproc.communicate()
72         retval = subproc.returncode
73         if retval != 0:
74             time.sleep(3)
75             log.warning('docker pull failed: %s' % cmd_stderr)
76             log.warning('retrying pulling image: %s' % name)
77         if count >= 5:
78             log.error('Failed to pull image: %s' % name)
79             break
80     if cmd_stdout:
81         log.debug(cmd_stdout)
82     if cmd_stderr:
83         log.debug(cmd_stderr)
84
85
86 def match_config_volume(prefix, config):
87     # Match the mounted config volume - we can't just use the
88     # key as e.g "novacomute" consumes config-data/nova
89     volumes = config.get('volumes', [])
90     config_volume=None
91     for v in volumes:
92         if v.startswith(prefix):
93             config_volume =  os.path.relpath(
94                 v.split(":")[0], prefix).split("/")[0]
95             break
96     return config_volume
97
98
99 def get_config_hash(prefix, config_volume):
100     hashfile = os.path.join(prefix, "%s.md5sum" % config_volume)
101     hash_data = None
102     if os.path.isfile(hashfile):
103         with open(hashfile) as f:
104             hash_data = f.read().rstrip()
105     return hash_data
106
107
108 def rm_container(name):
109     if os.environ.get('SHOW_DIFF', None):
110         log.info('Diffing container: %s' % name)
111         subproc = subprocess.Popen(['/usr/bin/docker', 'diff', name],
112                                    stdout=subprocess.PIPE,
113                                    stderr=subprocess.PIPE)
114         cmd_stdout, cmd_stderr = subproc.communicate()
115         if cmd_stdout:
116             log.debug(cmd_stdout)
117         if cmd_stderr:
118             log.debug(cmd_stderr)
119
120     log.info('Removing container: %s' % name)
121     subproc = subprocess.Popen(['/usr/bin/docker', 'rm', name],
122                                stdout=subprocess.PIPE,
123                                stderr=subprocess.PIPE)
124     cmd_stdout, cmd_stderr = subproc.communicate()
125     if cmd_stdout:
126         log.debug(cmd_stdout)
127     if cmd_stderr and \
128            cmd_stderr != 'Error response from daemon: ' \
129            'No such container: {}\n'.format(name):
130         log.debug(cmd_stderr)
131
132 process_count = int(os.environ.get('PROCESS_COUNT',
133                                    multiprocessing.cpu_count()))
134 log = get_logger()
135 log.info('Running docker-puppet')
136 config_file = os.environ.get('CONFIG', '/var/lib/docker-puppet/docker-puppet.json')
137 log.debug('CONFIG: %s' % config_file)
138 with open(config_file) as f:
139     json_data = json.load(f)
140
141 # To save time we support configuring 'shared' services at the same
142 # time. For example configuring all of the heat services
143 # in a single container pass makes sense and will save some time.
144 # To support this we merge shared settings together here.
145 #
146 # We key off of config_volume as this should be the same for a
147 # given group of services.  We are also now specifying the container
148 # in which the services should be configured.  This should match
149 # in all instances where the volume name is also the same.
150
151 configs = {}
152
153 for service in (json_data or []):
154     if service is None:
155         continue
156     if isinstance(service, dict):
157         service = [
158             service.get('config_volume'),
159             service.get('puppet_tags'),
160             service.get('step_config'),
161             service.get('config_image'),
162             service.get('volumes', []),
163         ]
164
165     config_volume = service[0] or ''
166     puppet_tags = service[1] or ''
167     manifest = service[2] or ''
168     config_image = service[3] or ''
169     volumes = service[4] if len(service) > 4 else []
170
171     if not manifest or not config_image:
172         continue
173
174     log.info('config_volume %s' % config_volume)
175     log.info('puppet_tags %s' % puppet_tags)
176     log.info('manifest %s' % manifest)
177     log.info('config_image %s' % config_image)
178     log.info('volumes %s' % volumes)
179     # We key off of config volume for all configs.
180     if config_volume in configs:
181         # Append puppet tags and manifest.
182         log.info("Existing service, appending puppet tags and manifest")
183         if puppet_tags:
184             configs[config_volume][1] = '%s,%s' % (configs[config_volume][1],
185                                                    puppet_tags)
186         if manifest:
187             configs[config_volume][2] = '%s\n%s' % (configs[config_volume][2],
188                                                     manifest)
189         if configs[config_volume][3] != config_image:
190             log.warn("Config containers do not match even though"
191                      " shared volumes are the same!")
192     else:
193         log.info("Adding new service")
194         configs[config_volume] = service
195
196 log.info('Service compilation completed.')
197
198 def mp_puppet_config((config_volume, puppet_tags, manifest, config_image, volumes)):
199     log = get_logger()
200     log.info('Started processing puppet configs')
201     log.debug('config_volume %s' % config_volume)
202     log.debug('puppet_tags %s' % puppet_tags)
203     log.debug('manifest %s' % manifest)
204     log.debug('config_image %s' % config_image)
205     log.debug('volumes %s' % volumes)
206     sh_script = '/var/lib/docker-puppet/docker-puppet.sh'
207
208     with open(sh_script, 'w') as script_file:
209         os.chmod(script_file.name, 0755)
210         script_file.write("""#!/bin/bash
211         set -ex
212         mkdir -p /etc/puppet
213         cp -a /tmp/puppet-etc/* /etc/puppet
214         rm -Rf /etc/puppet/ssl # not in use and causes permission errors
215         echo "{\\"step\\": $STEP}" > /etc/puppet/hieradata/docker.json
216         TAGS=""
217         if [ -n "$PUPPET_TAGS" ]; then
218             TAGS="--tags \"$PUPPET_TAGS\""
219         fi
220
221         # Create a reference timestamp to easily find all files touched by
222         # puppet. The sync ensures we get all the files we want due to
223         # different timestamp.
224         touch /tmp/the_origin_of_time
225         sync
226
227         FACTER_hostname=$HOSTNAME FACTER_uuid=docker /usr/bin/puppet apply \
228         --color=false --logdest syslog --logdest console $TAGS /etc/config.pp
229
230         # Disables archiving
231         if [ -z "$NO_ARCHIVE" ]; then
232             archivedirs=("/etc" "/root" "/opt" "/var/lib/ironic/tftpboot" "/var/lib/ironic/httpboot" "/var/www" "/var/spool/cron" "/var/lib/nova/.ssh")
233             rsync_srcs=""
234             for d in "${archivedirs[@]}"; do
235                 if [ -d "$d" ]; then
236                     rsync_srcs+=" $d"
237                 fi
238             done
239             rsync -a -R --delay-updates --delete-after $rsync_srcs /var/lib/config-data/${NAME}
240
241             # Also make a copy of files modified during puppet run
242             # This is useful for debugging
243             mkdir -p /var/lib/config-data/puppet-generated/${NAME}
244             rsync -a -R -0 --delay-updates --delete-after \
245                           --files-from=<(find $rsync_srcs -newer /tmp/the_origin_of_time -not -path '/etc/puppet*' -print0) \
246                           / /var/lib/config-data/puppet-generated/${NAME}
247
248             # Write a checksum of the config-data dir, this is used as a
249             # salt to trigger container restart when the config changes
250             tar -c -f - /var/lib/config-data/${NAME} --mtime='1970-01-01' | md5sum | awk '{print $1}' > /var/lib/config-data/${NAME}.md5sum
251         fi
252         """)
253
254     with tempfile.NamedTemporaryFile() as tmp_man:
255         with open(tmp_man.name, 'w') as man_file:
256             man_file.write('include ::tripleo::packages\n')
257             man_file.write(manifest)
258
259         rm_container('docker-puppet-%s' % config_volume)
260         pull_image(config_image)
261
262         dcmd = ['/usr/bin/docker', 'run',
263                 '--user', 'root',
264                 '--name', 'docker-puppet-%s' % config_volume,
265                 '--health-cmd', '/bin/true',
266                 '--env', 'PUPPET_TAGS=%s' % puppet_tags,
267                 '--env', 'NAME=%s' % config_volume,
268                 '--env', 'HOSTNAME=%s' % short_hostname(),
269                 '--env', 'NO_ARCHIVE=%s' % os.environ.get('NO_ARCHIVE', ''),
270                 '--env', 'STEP=%s' % os.environ.get('STEP', '6'),
271                 '--volume', '%s:/etc/config.pp:ro' % tmp_man.name,
272                 '--volume', '/etc/puppet/:/tmp/puppet-etc/:ro',
273                 '--volume', '/usr/share/openstack-puppet/modules/:/usr/share/openstack-puppet/modules/:ro',
274                 '--volume', '%s:/var/lib/config-data/:rw' % os.environ.get('CONFIG_VOLUME_PREFIX', '/var/lib/config-data'),
275                 '--volume', 'tripleo_logs:/var/log/tripleo/',
276                 # Syslog socket for puppet logs
277                 '--volume', '/dev/log:/dev/log',
278                 # OpenSSL trusted CA injection
279                 '--volume', '/etc/pki/ca-trust/extracted:/etc/pki/ca-trust/extracted:ro',
280                 '--volume', '/etc/pki/tls/certs/ca-bundle.crt:/etc/pki/tls/certs/ca-bundle.crt:ro',
281                 '--volume', '/etc/pki/tls/certs/ca-bundle.trust.crt:/etc/pki/tls/certs/ca-bundle.trust.crt:ro',
282                 '--volume', '/etc/pki/tls/cert.pem:/etc/pki/tls/cert.pem:ro',
283                 # script injection
284                 '--volume', '%s:%s:rw' % (sh_script, sh_script) ]
285
286         for volume in volumes:
287             if volume:
288                 dcmd.extend(['--volume', volume])
289
290         dcmd.extend(['--entrypoint', sh_script])
291
292         env = {}
293         # NOTE(flaper87): Always copy the DOCKER_* environment variables as
294         # they contain the access data for the docker daemon.
295         for k in filter(lambda k: k.startswith('DOCKER'), os.environ.keys()):
296             env[k] = os.environ.get(k)
297
298         if os.environ.get('NET_HOST', 'false') == 'true':
299             log.debug('NET_HOST enabled')
300             dcmd.extend(['--net', 'host', '--volume',
301                          '/etc/hosts:/etc/hosts:ro'])
302         dcmd.append(config_image)
303         log.debug('Running docker command: %s' % ' '.join(dcmd))
304
305         subproc = subprocess.Popen(dcmd, stdout=subprocess.PIPE,
306                                    stderr=subprocess.PIPE, env=env)
307         cmd_stdout, cmd_stderr = subproc.communicate()
308         if subproc.returncode != 0:
309             log.error('Failed running docker-puppet.py for %s' % config_volume)
310             if cmd_stdout:
311                 log.error(cmd_stdout)
312             if cmd_stderr:
313                 log.error(cmd_stderr)
314         else:
315             if cmd_stdout:
316                 log.debug(cmd_stdout)
317             if cmd_stderr:
318                 log.debug(cmd_stderr)
319             # only delete successful runs, for debugging
320             rm_container('docker-puppet-%s' % config_volume)
321
322         log.info('Finished processing puppet configs')
323         return subproc.returncode
324
325 # Holds all the information for each process to consume.
326 # Instead of starting them all linearly we run them using a process
327 # pool.  This creates a list of arguments for the above function
328 # to consume.
329 process_map = []
330
331 for config_volume in configs:
332
333     service = configs[config_volume]
334     puppet_tags = service[1] or ''
335     manifest = service[2] or ''
336     config_image = service[3] or ''
337     volumes = service[4] if len(service) > 4 else []
338
339     if puppet_tags:
340         puppet_tags = "file,file_line,concat,augeas,cron,%s" % puppet_tags
341     else:
342         puppet_tags = "file,file_line,concat,augeas,cron"
343
344     process_map.append([config_volume, puppet_tags, manifest, config_image, volumes])
345
346 for p in process_map:
347     log.debug('- %s' % p)
348
349 # Fire off processes to perform each configuration.  Defaults
350 # to the number of CPUs on the system.
351 p = multiprocessing.Pool(process_count)
352 returncodes = list(p.map(mp_puppet_config, process_map))
353 config_volumes = [pm[0] for pm in process_map]
354 success = True
355 for returncode, config_volume in zip(returncodes, config_volumes):
356     if returncode != 0:
357         log.error('ERROR configuring %s' % config_volume)
358         success = False
359
360
361 # Update the startup configs with the config hash we generated above
362 config_volume_prefix = os.environ.get('CONFIG_VOLUME_PREFIX', '/var/lib/config-data')
363 log.debug('CONFIG_VOLUME_PREFIX: %s' % config_volume_prefix)
364 startup_configs = os.environ.get('STARTUP_CONFIG_PATTERN', '/var/lib/tripleo-config/docker-container-startup-config-step_*.json')
365 log.debug('STARTUP_CONFIG_PATTERN: %s' % startup_configs)
366 infiles = glob.glob('/var/lib/tripleo-config/docker-container-startup-config-step_*.json')
367 for infile in infiles:
368     with open(infile) as f:
369         infile_data = json.load(f)
370
371     for k, v in infile_data.iteritems():
372         config_volume = match_config_volume(config_volume_prefix, v)
373         if config_volume:
374             config_hash = get_config_hash(config_volume_prefix, config_volume)
375             if config_hash:
376                 env = v.get('environment', [])
377                 env.append("TRIPLEO_CONFIG_HASH=%s" % config_hash)
378                 log.debug("Updating config hash for %s, config_volume=%s hash=%s" % (k, config_volume, config_hash))
379                 infile_data[k]['environment'] = env
380
381     outfile = os.path.join(os.path.dirname(infile), "hashed-" + os.path.basename(infile))
382     with open(outfile, 'w') as out_f:
383         os.chmod(out_f.name, 0600)
384         json.dump(infile_data, out_f)
385
386 if not success:
387     sys.exit(1)