Add --detailed-exitcodes when running puppet via ansible
[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.dirname(v.split(":")[0])
94             break
95     return config_volume
96
97
98 def get_config_hash(config_volume):
99     hashfile = "%s.md5sum" % config_volume
100     log.debug("Looking for hashfile %s for config_volume %s" % (hashfile, config_volume))
101     hash_data = None
102     if os.path.isfile(hashfile):
103         log.debug("Got hashfile %s for config_volume %s" % (hashfile, config_volume))
104         with open(hashfile) as f:
105             hash_data = f.read().rstrip()
106     return hash_data
107
108
109 def rm_container(name):
110     if os.environ.get('SHOW_DIFF', None):
111         log.info('Diffing container: %s' % name)
112         subproc = subprocess.Popen(['/usr/bin/docker', 'diff', name],
113                                    stdout=subprocess.PIPE,
114                                    stderr=subprocess.PIPE)
115         cmd_stdout, cmd_stderr = subproc.communicate()
116         if cmd_stdout:
117             log.debug(cmd_stdout)
118         if cmd_stderr:
119             log.debug(cmd_stderr)
120
121     log.info('Removing container: %s' % name)
122     subproc = subprocess.Popen(['/usr/bin/docker', 'rm', name],
123                                stdout=subprocess.PIPE,
124                                stderr=subprocess.PIPE)
125     cmd_stdout, cmd_stderr = subproc.communicate()
126     if cmd_stdout:
127         log.debug(cmd_stdout)
128     if cmd_stderr and \
129            cmd_stderr != 'Error response from daemon: ' \
130            'No such container: {}\n'.format(name):
131         log.debug(cmd_stderr)
132
133 process_count = int(os.environ.get('PROCESS_COUNT',
134                                    multiprocessing.cpu_count()))
135 log = get_logger()
136 log.info('Running docker-puppet')
137 config_file = os.environ.get('CONFIG', '/var/lib/docker-puppet/docker-puppet.json')
138 log.debug('CONFIG: %s' % config_file)
139 with open(config_file) as f:
140     json_data = json.load(f)
141
142 # To save time we support configuring 'shared' services at the same
143 # time. For example configuring all of the heat services
144 # in a single container pass makes sense and will save some time.
145 # To support this we merge shared settings together here.
146 #
147 # We key off of config_volume as this should be the same for a
148 # given group of services.  We are also now specifying the container
149 # in which the services should be configured.  This should match
150 # in all instances where the volume name is also the same.
151
152 configs = {}
153
154 for service in (json_data or []):
155     if service is None:
156         continue
157     if isinstance(service, dict):
158         service = [
159             service.get('config_volume'),
160             service.get('puppet_tags'),
161             service.get('step_config'),
162             service.get('config_image'),
163             service.get('volumes', []),
164         ]
165
166     config_volume = service[0] or ''
167     puppet_tags = service[1] or ''
168     manifest = service[2] or ''
169     config_image = service[3] or ''
170     volumes = service[4] if len(service) > 4 else []
171
172     if not manifest or not config_image:
173         continue
174
175     log.info('config_volume %s' % config_volume)
176     log.info('puppet_tags %s' % puppet_tags)
177     log.info('manifest %s' % manifest)
178     log.info('config_image %s' % config_image)
179     log.info('volumes %s' % volumes)
180     # We key off of config volume for all configs.
181     if config_volume in configs:
182         # Append puppet tags and manifest.
183         log.info("Existing service, appending puppet tags and manifest")
184         if puppet_tags:
185             configs[config_volume][1] = '%s,%s' % (configs[config_volume][1],
186                                                    puppet_tags)
187         if manifest:
188             configs[config_volume][2] = '%s\n%s' % (configs[config_volume][2],
189                                                     manifest)
190         if configs[config_volume][3] != config_image:
191             log.warn("Config containers do not match even though"
192                      " shared volumes are the same!")
193     else:
194         log.info("Adding new service")
195         configs[config_volume] = service
196
197 log.info('Service compilation completed.')
198
199 def mp_puppet_config((config_volume, puppet_tags, manifest, config_image, volumes)):
200     log = get_logger()
201     log.info('Started processing puppet configs')
202     log.debug('config_volume %s' % config_volume)
203     log.debug('puppet_tags %s' % puppet_tags)
204     log.debug('manifest %s' % manifest)
205     log.debug('config_image %s' % config_image)
206     log.debug('volumes %s' % volumes)
207     sh_script = '/var/lib/docker-puppet/docker-puppet.sh'
208
209     with open(sh_script, 'w') as script_file:
210         os.chmod(script_file.name, 0755)
211         script_file.write("""#!/bin/bash
212         set -ex
213         mkdir -p /etc/puppet
214         cp -a /tmp/puppet-etc/* /etc/puppet
215         rm -Rf /etc/puppet/ssl # not in use and causes permission errors
216         echo "{\\"step\\": $STEP}" > /etc/puppet/hieradata/docker.json
217         TAGS=""
218         if [ -n "$PUPPET_TAGS" ]; then
219             TAGS="--tags \"$PUPPET_TAGS\""
220         fi
221
222         # Create a reference timestamp to easily find all files touched by
223         # puppet. The sync ensures we get all the files we want due to
224         # different timestamp.
225         touch /tmp/the_origin_of_time
226         sync
227
228         set +e
229         FACTER_hostname=$HOSTNAME FACTER_uuid=docker /usr/bin/puppet apply \
230         --detailed-exitcodes --color=false --logdest syslog --logdest console $TAGS /etc/config.pp
231         rc=$?
232         set -e
233         if [ $rc -ne 2 -a $rc -ne 0 ]; then
234             exit $rc
235         fi
236
237         # Disables archiving
238         if [ -z "$NO_ARCHIVE" ]; then
239             archivedirs=("/etc" "/root" "/opt" "/var/lib/ironic/tftpboot" "/var/lib/ironic/httpboot" "/var/www" "/var/spool/cron" "/var/lib/nova/.ssh")
240             rsync_srcs=""
241             for d in "${archivedirs[@]}"; do
242                 if [ -d "$d" ]; then
243                     rsync_srcs+=" $d"
244                 fi
245             done
246             rsync -a -R --delay-updates --delete-after $rsync_srcs /var/lib/config-data/${NAME}
247
248             # Also make a copy of files modified during puppet run
249             # This is useful for debugging
250             mkdir -p /var/lib/config-data/puppet-generated/${NAME}
251             rsync -a -R -0 --delay-updates --delete-after \
252                           --files-from=<(find $rsync_srcs -newer /tmp/the_origin_of_time -not -path '/etc/puppet*' -print0) \
253                           / /var/lib/config-data/puppet-generated/${NAME}
254
255             # Write a checksum of the config-data dir, this is used as a
256             # salt to trigger container restart when the config changes
257             tar -c -f - /var/lib/config-data/${NAME} --mtime='1970-01-01' | md5sum | awk '{print $1}' > /var/lib/config-data/${NAME}.md5sum
258             tar -c -f - /var/lib/config-data/puppet-generated/${NAME} --mtime='1970-01-01' | md5sum | awk '{print $1}' > /var/lib/config-data/puppet-generated/${NAME}.md5sum
259         fi
260         """)
261
262     with tempfile.NamedTemporaryFile() as tmp_man:
263         with open(tmp_man.name, 'w') as man_file:
264             man_file.write('include ::tripleo::packages\n')
265             man_file.write(manifest)
266
267         rm_container('docker-puppet-%s' % config_volume)
268         pull_image(config_image)
269
270         dcmd = ['/usr/bin/docker', 'run',
271                 '--user', 'root',
272                 '--name', 'docker-puppet-%s' % config_volume,
273                 '--health-cmd', '/bin/true',
274                 '--env', 'PUPPET_TAGS=%s' % puppet_tags,
275                 '--env', 'NAME=%s' % config_volume,
276                 '--env', 'HOSTNAME=%s' % short_hostname(),
277                 '--env', 'NO_ARCHIVE=%s' % os.environ.get('NO_ARCHIVE', ''),
278                 '--env', 'STEP=%s' % os.environ.get('STEP', '6'),
279                 '--volume', '%s:/etc/config.pp:ro' % tmp_man.name,
280                 '--volume', '/etc/puppet/:/tmp/puppet-etc/:ro',
281                 '--volume', '/usr/share/openstack-puppet/modules/:/usr/share/openstack-puppet/modules/:ro',
282                 '--volume', '%s:/var/lib/config-data/:rw' % os.environ.get('CONFIG_VOLUME_PREFIX', '/var/lib/config-data'),
283                 '--volume', 'tripleo_logs:/var/log/tripleo/',
284                 # Syslog socket for puppet logs
285                 '--volume', '/dev/log:/dev/log',
286                 # OpenSSL trusted CA injection
287                 '--volume', '/etc/pki/ca-trust/extracted:/etc/pki/ca-trust/extracted:ro',
288                 '--volume', '/etc/pki/tls/certs/ca-bundle.crt:/etc/pki/tls/certs/ca-bundle.crt:ro',
289                 '--volume', '/etc/pki/tls/certs/ca-bundle.trust.crt:/etc/pki/tls/certs/ca-bundle.trust.crt:ro',
290                 '--volume', '/etc/pki/tls/cert.pem:/etc/pki/tls/cert.pem:ro',
291                 # script injection
292                 '--volume', '%s:%s:rw' % (sh_script, sh_script) ]
293
294         for volume in volumes:
295             if volume:
296                 dcmd.extend(['--volume', volume])
297
298         dcmd.extend(['--entrypoint', sh_script])
299
300         env = {}
301         # NOTE(flaper87): Always copy the DOCKER_* environment variables as
302         # they contain the access data for the docker daemon.
303         for k in filter(lambda k: k.startswith('DOCKER'), os.environ.keys()):
304             env[k] = os.environ.get(k)
305
306         if os.environ.get('NET_HOST', 'false') == 'true':
307             log.debug('NET_HOST enabled')
308             dcmd.extend(['--net', 'host', '--volume',
309                          '/etc/hosts:/etc/hosts:ro'])
310         dcmd.append(config_image)
311         log.debug('Running docker command: %s' % ' '.join(dcmd))
312
313         subproc = subprocess.Popen(dcmd, stdout=subprocess.PIPE,
314                                    stderr=subprocess.PIPE, env=env)
315         cmd_stdout, cmd_stderr = subproc.communicate()
316         # puppet with --detailed-exitcodes will return 0 for success and no changes
317         # and 2 for success and resource changes. Other numbers are failures
318         if subproc.returncode not in [0, 2]:
319             log.error('Failed running docker-puppet.py for %s' % config_volume)
320             if cmd_stdout:
321                 log.error(cmd_stdout)
322             if cmd_stderr:
323                 log.error(cmd_stderr)
324         else:
325             if cmd_stdout:
326                 log.debug(cmd_stdout)
327             if cmd_stderr:
328                 log.debug(cmd_stderr)
329             # only delete successful runs, for debugging
330             rm_container('docker-puppet-%s' % config_volume)
331
332         log.info('Finished processing puppet configs')
333         return subproc.returncode
334
335 # Holds all the information for each process to consume.
336 # Instead of starting them all linearly we run them using a process
337 # pool.  This creates a list of arguments for the above function
338 # to consume.
339 process_map = []
340
341 for config_volume in configs:
342
343     service = configs[config_volume]
344     puppet_tags = service[1] or ''
345     manifest = service[2] or ''
346     config_image = service[3] or ''
347     volumes = service[4] if len(service) > 4 else []
348
349     if puppet_tags:
350         puppet_tags = "file,file_line,concat,augeas,cron,%s" % puppet_tags
351     else:
352         puppet_tags = "file,file_line,concat,augeas,cron"
353
354     process_map.append([config_volume, puppet_tags, manifest, config_image, volumes])
355
356 for p in process_map:
357     log.debug('- %s' % p)
358
359 # Fire off processes to perform each configuration.  Defaults
360 # to the number of CPUs on the system.
361 p = multiprocessing.Pool(process_count)
362 returncodes = list(p.map(mp_puppet_config, process_map))
363 config_volumes = [pm[0] for pm in process_map]
364 success = True
365 for returncode, config_volume in zip(returncodes, config_volumes):
366     if returncode not in [0, 2]:
367         log.error('ERROR configuring %s' % config_volume)
368         success = False
369
370
371 # Update the startup configs with the config hash we generated above
372 config_volume_prefix = os.environ.get('CONFIG_VOLUME_PREFIX', '/var/lib/config-data')
373 log.debug('CONFIG_VOLUME_PREFIX: %s' % config_volume_prefix)
374 startup_configs = os.environ.get('STARTUP_CONFIG_PATTERN', '/var/lib/tripleo-config/docker-container-startup-config-step_*.json')
375 log.debug('STARTUP_CONFIG_PATTERN: %s' % startup_configs)
376 infiles = glob.glob('/var/lib/tripleo-config/docker-container-startup-config-step_*.json')
377 for infile in infiles:
378     with open(infile) as f:
379         infile_data = json.load(f)
380
381     for k, v in infile_data.iteritems():
382         config_volume = match_config_volume(config_volume_prefix, v)
383         if config_volume:
384             config_hash = get_config_hash(config_volume)
385             if config_hash:
386                 env = v.get('environment', [])
387                 env.append("TRIPLEO_CONFIG_HASH=%s" % config_hash)
388                 log.debug("Updating config hash for %s, config_volume=%s hash=%s" % (k, config_volume, config_hash))
389                 infile_data[k]['environment'] = env
390
391     outfile = os.path.join(os.path.dirname(infile), "hashed-" + os.path.basename(infile))
392     with open(outfile, 'w') as out_f:
393         os.chmod(out_f.name, 0600)
394         json.dump(infile_data, out_f)
395
396 if not success:
397     sys.exit(1)