blob: fc54c91e191fa77ccf4e42d5c04d0ddc657ea631 [file] [log] [blame]
Zack Williamsce63eb02017-02-28 10:46:22 -07001#!/usr/bin/env python
2
3# imagebuilder.py
4# rebuilds/fetches docker container images per their git status in repo
5# in addition to docker, needs `sudo apt-get install python-git`
6
7import argparse
8import datetime
9import git
10import json
11import logging
12import os
13import re
14import string
15import sys
16import tarfile
17import tempfile
18import time
19import xml.etree.ElementTree as ET
20import yaml
21
22global args
23global conf
24global build_tag
25global buildable_images
26global pull_only_images
27
28
29def setup_logging(name=None, logfile=False):
30 global args
31
32 if name:
33 log = logging.getLogger("-".join([__name__, name]))
34 else:
35 log = logging.getLogger(__name__)
36
37 slh = logging.StreamHandler(sys.stdout)
38 slh.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
39 slh.setLevel(logging.DEBUG)
40
41 log.addHandler(slh)
42
43 # secondary logging to a file, always DEBUG level
44 if logfile:
45 fn = os.path.join(conf.logdir, "%s.log" % name)
46 flh = logging.FileHandler(fn)
47 flh.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
48 flh.setLevel(logging.DEBUG)
49 log.addHandler(flh)
50
51 return log
52
53LOG = setup_logging()
54
55
56def parse_args():
57 global args
58
59 parser = argparse.ArgumentParser()
60
61 parser.add_argument('-c', '--container_list', default='docker_images.yml',
62 type=argparse.FileType('r'),
63 help="YAML Config and master container list")
64
65 # -f is optional, so using type=argparse.FileType is problematic
66 parser.add_argument('-f', '--filter_images', default=None, action='store',
67 help="YAML file restricting images to build/fetch")
68
69 parser.add_argument('-a', '--actions_taken', default=None,
70 help="Save a YAML file with actions taken during run")
71
72 # FIXME - the -b and -p options are currently unimplemented
73 group = parser.add_mutually_exclusive_group()
74
75 group.add_argument('-b', '--build_force', action="store_true",
76 help="Build (don't fetch) all internal containers")
77
78 group.add_argument('-p', '--pull_force', action="store_true",
79 help="Only pull containers, fail if build required")
80
81 parser.add_argument('-d', '--dry_run', action="store_true",
82 help="Don't build/fetch anything")
83
84 parser.add_argument('-g', '--graph', default=None,
85 help="Filename for DOT graph file of image dependency")
86
87 parser.add_argument('-l', '--build_log_dir', action="store",
88 help="Log build output to this dir if set")
89
90 parser.add_argument('-r', '--repo_root', default="..", action="store",
91 help="Repo root directory")
92
93 parser.add_argument('-t', '--build_tag', default=None, action="store",
94 help="tag all images built/pulled using this tag")
95
96 parser.add_argument('-v', '--verbosity', action='count', default=1,
97 help="Repeat to increase log level")
98
99 args = parser.parse_args()
100
101 if args.verbosity > 1:
102 LOG.setLevel(logging.DEBUG)
103 else:
104 LOG.setLevel(logging.INFO)
105
106
107def load_config():
108 global args
109 global conf
110 global buildable_images
111 global pull_only_images
112 global build_tag
113
114 try:
115 cl_abs = os.path.abspath(args.container_list.name)
116 LOG.info("Master container list file: %s" % cl_abs)
117
118 conf = yaml.safe_load(args.container_list)
119 except yaml.YAMLError:
120 LOG.exception("Problem loading container list file")
121 sys.exit(1)
122
123 if args.build_tag:
124 build_tag = args.build_tag
125 else:
126 build_tag = conf['docker_build_tag']
127
128 if args.filter_images is None:
129 buildable_images = conf['buildable_images']
130 pull_only_images = conf['pull_only_images']
131 else:
132 fi_abs = os.path.abspath(args.filter_images)
133
134 LOG.info("Filtering image list per 'docker_image_whitelist' in: %s" %
135 fi_abs)
136 try:
137 fi_fh = open(fi_abs, 'r')
138 filter_list = yaml.safe_load(fi_fh)
139 fi_fh.close()
140
141 if 'docker_image_whitelist' not in filter_list:
142 LOG.error("No 'docker_image_whitelist' defined in: %s" %
143 fi_abs)
144 sys.exit(1)
145
146 # fail if filter list specifies tags
147 for f_i in filter_list['docker_image_whitelist']:
148 (name, tag) = split_name(f_i)
149 if tag:
150 LOG.error("filter list may not be tagged")
151 sys.exit(1)
152
153 buildable_images = [img for img in conf['buildable_images']
154 if split_name(img['name'])[0]
155 in filter_list['docker_image_whitelist']]
156
157 pull_only_images = [img for img in conf['pull_only_images']
158 if split_name(img)[0]
159 in filter_list['docker_image_whitelist']]
160
161 except:
162 LOG.exception("Problem with filter list file")
163 sys.exit(1)
164
165
166def split_name(input_name):
167 """ split a docker image name in the 'name:tag' format into components """
168
169 name = input_name
170 tag = None
171
172 # split name:tag if given in combined format
173 name_tag_split = string.split(input_name, ":")
174
175 if len(name_tag_split) > 1: # has tag, return separated version
176 name = name_tag_split[0]
177 tag = name_tag_split[1]
178
179 return (name, tag)
180
181
182class RepoRepo():
183 """ git repo managed by repo tool"""
184
185 manifest_branch = ""
186
187 def __init__(self, name, path, remote):
188
189 self.name = name
190 self.path = path
191 self.remote = remote
192 self.git_url = "%s%s" % (remote, name)
193
194 try:
195 self.git_repo_o = git.Repo(self.abspath())
196 LOG.debug("Repo - %s, path: %s" % (name, path))
197
198 self.head_commit = self.git_repo_o.head.commit.hexsha
199 LOG.debug(" head commit: %s" % self.head_commit)
200
201 commit_t = time.gmtime(self.git_repo_o.head.commit.committed_date)
202 self.head_commit_t = time.strftime("%Y-%m-%dT%H:%M:%SZ", commit_t)
203 LOG.debug(" commit date: %s" % self.head_commit_t)
204
205 self.clean = not self.git_repo_o.is_dirty(untracked_files=True)
206 LOG.debug(" clean: %s" % self.clean)
207
208 # list of untracked files (expensive operation)
209 self.untracked_files = self.git_repo_o.untracked_files
210 for u_file in self.untracked_files:
211 LOG.debug(" Untracked: %s" % u_file)
212
213 except Exception:
214 LOG.exception("Error with git repo: %s" % name)
215 sys.exit(1)
216
217 def abspath(self):
218 global args
219 return os.path.abspath(os.path.join(args.repo_root, self.path))
220
221 def path_clean(self, test_path, branch=""):
222 """ Is working tree on branch and no untracked files in path? """
223 global conf
224
225 if not branch:
226 branch = self.manifest_branch
227
228 LOG.debug(" Looking for changes in path: %s" % test_path)
229
230 p_clean = True
231
232 # diff between branch head and working tree (None)
233 branch_head = self.git_repo_o.commit(branch)
234 diff = branch_head.diff(None, paths=test_path)
235
236 if diff:
237 p_clean = False
238
239 for diff_obj in diff:
240 LOG.debug(" file not on branch: %s" % diff_obj)
241
242 # remove . to compare paths using .startswith()
243 if test_path == ".":
244 test_path = ""
245
246 for u_file in self.untracked_files:
247 if u_file.startswith(test_path):
248 LOG.debug(" untracked file in path: %s" % u_file)
249 p_clean = False
250
251 return p_clean
252
253
254class RepoManifest():
255 """ parses manifest XML file used by repo tool"""
256
257 def __init__(self):
258 global args
259 global conf
260
261 self.manifest_xml = {}
262 self.repos = {}
263 self.branch = ""
264
265 self.manifest_file = os.path.abspath(
266 os.path.join(args.repo_root,
267 ".repo/manifest.xml"))
268
269 LOG.info("Loading manifest file: %s" % self.manifest_file)
270
271 try:
272 tree = ET.parse(self.manifest_file)
273 self.manifest_xml = tree.getroot()
274 except Exception:
275 LOG.exception("Error loading repo manifest")
276 sys.exit(1)
277
278 # Find the default branch
279 default = self.manifest_xml.find('default')
280 self.branch = "%s/%s" % (default.attrib['remote'],
281 default.attrib['revision'])
282
283 # Find the remote URL for these repos
284 remote = self.manifest_xml.find('remote')
285 self.remote = remote.attrib['review']
286
287 LOG.info("Manifest is on branch '%s' with remote '%s'" %
288 (self.branch, self.remote))
289
290 project_repos = {}
291
292 for project in self.manifest_xml.iter('project'):
293 repo_name = project.attrib['name']
294 rel_path = project.attrib['path']
295 abs_path = os.path.abspath(os.path.join(args.repo_root,
296 project.attrib['path']))
297
298 if os.path.isdir(abs_path):
299 project_repos[repo_name] = rel_path
300 else:
301 LOG.debug("Repo in manifest but not checked out: %s" %
302 repo_name)
303
304 for repo_name, repo_path in project_repos.iteritems():
305 self.repos[repo_name] = RepoRepo(repo_name, repo_path, self.remote)
306 self.repos[repo_name].manifest_branch = self.branch
307
308 def get_repo(self, repo_name):
309 return self.repos[repo_name]
310
311# DockerImage Status Constants
312
313DI_UNKNOWN = 'unknown' # unknown status
314DI_EXISTS = 'exists' # already exists in docker, has an image_id
315
316DI_BUILD = 'build' # needs to be built
317DI_FETCH = 'fetch' # needs to be fetched (pulled)
318DI_ERROR = 'error' # build or other fatal failure
319
320
321class DockerImage():
322
323 def __init__(self, name, repo_name=None, repo_d=None, path=".",
324 context=".", dockerfile='Dockerfile', labels=None,
325 tags=None, image_id=None, components=None, status=DI_UNKNOWN):
326
327 LOG.debug("New DockerImage object from name: %s" % name)
328
329 # name to pull as, usually what is provided on creation.
330 # May be changed by create_tags
331 self.raw_name = name
332
333 # Python's mutable defaults is a landmine
334 if labels is None:
335 self.labels = {}
336 else:
337 self.labels = labels
338
339 self.repo_name = repo_name
340 self.repo_d = repo_d
341 self.path = path
342 self.context = context
343 self.dockerfile = dockerfile
344 self.tags = [] # tags are added to this later in __init__
345 self.image_id = image_id
346 self.components = components
347 self.status = status
348
349 self.parent_name = None # set by _find_parent_name()
350 self.parent = None # pointer to parent DockerImage object
351 self.children = [] # list of child DockerImage objects
352
353 # split name:tag if given in combined format
354 (image_name, image_tag) = split_name(name)
355 if image_tag: # has tag
356 self.name = image_name
357 self.tags.append(image_tag)
358 else: # no tag
359 self.name = image_name
360
361 # Add the build tag if exists
362 if build_tag not in self.tags:
363 self.tags.append(build_tag)
364
365 # split names from tag list
366 if tags is not None:
367 for tag in tags:
368 thistag = ""
369 (tag_name, tag_tag) = split_name(tag)
370 if tag_tag: # has name also, use just tag
371 thistag = tag_tag
372 else: # just a bare tag
373 thistag = tag_name
374
375 if thistag not in self.tags: # don't duplicate tags
376 self.tags.append(thistag)
377
378 # self.clean only applies to this container
379 self.clean = self._context_clean()
380 self._find_parent_name()
381
382 def __str__(self):
383 return self.name
384
385 def buildable(self):
386 """ Can this image be built from a Dockerfile? """
387 if self.repo_name: # has a git repo to be built from
388 return True
389 return False
390
391 def _context_clean(self):
392 """ Determine if this is repo and context is clean """
393
394 if self.buildable():
395
396 # check if on master branch
397 repo_clean = self.repo_d.clean
398
399 # only check the Docker context for cleanliness
400 context_path = os.path.normpath(
401 os.path.join(self.path, self.context))
402 context_clean = self.repo_d.path_clean(context_path)
403
404 # check of subcomponents are clean
405 components_clean = self.components_clean()
406
407 LOG.debug(" Build Context Cleanliness -")
408 LOG.debug(" repo: %s, context: %s, components: %s" %
409 (repo_clean, context_clean, components_clean))
410
411 if context_clean and repo_clean and components_clean:
412 return True
413 else:
414 return False
415
416 return True # unbuildable images are clean
417
418 def parent_clean(self):
419 """ if all parents are clean """
420
421 if self.buildable():
422 if self.clean and self.parent.parent_clean():
423 return True
424 else:
425 return False
426
427 return True # unbuildable images are clean
428
429 def compare_labels(self, other_labels):
430 """ Returns True if image label-schema.org labels match dict """
431
432 comparable_labels_re = [
433 r".*name$",
434 r".*vcs-url$",
435 r".*vcs-ref$",
436 ]
437
438 for clr in comparable_labels_re: # loop on all comparable labels
439 for label in self.labels: # loop on all labels
440 if re.match(clr, label) is not None: # if label matches re
441 # and label exists in other, and values are same
442 if label in other_labels and \
443 self.labels[label] == other_labels[label]:
444 pass # continue through loop
445 else:
446 LOG.info("Non-matching label: %s" % label)
447 return False # False when first difference found
448
449 return True # only when every label matches
450
451 def same_name(self, other_name):
452 """ compare image name (possibly with tag) against image name/tag """
453
454 (o_name, o_tag) = split_name(other_name)
455
456 if o_tag is None and self.name == o_name:
457 return True
458 elif self.name == o_name and o_tag in self.tags:
459 return True
460
461 return False
462
463 def components_clean(self):
464
465 if self.buildable() and self.components is not None:
466 for component in self.components:
467 if not component['repo_d'].clean or \
468 not component['repo_d'].path_clean(component['path']):
469 return False
470
471 return True
472
473 def component_labels(self):
474 """ returns a dict of labels for subcomponents """
475
476 if self.buildable() and self.components is not None:
477
478 comp_l = {}
479
480 for component in self.components:
481
482 LOG.debug(" component %s generating child labels" %
483 component['repo_name'])
484
485 prefix = "org.opencord.component.%s." % component['repo_name']
486
487 comp_l[prefix + "vcs-url"] = component['repo_d'].git_url
488
489 if component['repo_d'].clean and \
490 component['repo_d'].path_clean(component['path']):
491 clean = True
492 else:
493 clean = False
494
495 if clean:
496 comp_l[prefix + "version"] = self.repo_d.manifest_branch
497 comp_l[prefix + "vcs-ref"] = \
498 component['repo_d'].head_commit
499 else:
500 comp_l[prefix + "version"] = "dirty"
501 comp_l[prefix + "vcs-ref"] = ""
502
503 return comp_l
504
505 return None
506
507 def child_labels(self, repo_list=None):
508 """ return a dict of labels to apply to child images """
509
510 LOG.debug(" Parent image %s generating child labels" % self.name)
511
512 # only create labels when they haven't already been created
513 if repo_list is None:
514 repo_list = []
515
516 LOG.debug(" Parents already labeled with: %s" % ", ".join(repo_list))
517
518 cl = {}
519
520 if self.buildable() and self.repo_name not in repo_list:
521
522 LOG.debug(" Adding parent labels from repo: %s" % self.repo_name)
523
524 prefix = "org.opencord.component.%s." % self.repo_name
525
526 cl[prefix + "vcs-url"] = self.repo_d.git_url
527
528 if self.clean:
529 cl[prefix + "version"] = self.repo_d.manifest_branch
530 cl[prefix + "vcs-ref"] = self.repo_d.head_commit
531 else:
532 cl[prefix + "version"] = "dirty"
533 cl[prefix + "vcs-ref"] = ""
534
535 repo_list.append(self.repo_name)
536
537 # include component labels if present
538 if self.components is not None:
539 cl.update(self.component_labels())
540
541 # recursively find labels up the parent chain
542 if self.parent is not None:
543 cl.update(self.parent.child_labels(repo_list))
544
545 return cl
546
547 def create_labels(self):
548 """ Create label-schema.org labels for image """
549
550 if self.buildable():
551
552 LOG.debug("Creating labels for: %s" % self.name)
553
554 self.labels['org.label-schema.name'] = self.name
555 self.labels['org.label-schema.schema-version'] = "1.0"
556
557 # org.label-schema.build-date
558 time_now = datetime.datetime.utcnow()
559 build_date = time_now.strftime("%Y-%m-%dT%H:%M:%SZ")
560 self.labels['org.label-schema.build-date'] = build_date
561
562 # git version related labels
563 self.labels['org.label-schema.vcs-url'] = self.repo_d.git_url
564
565 if self.clean:
566 self.labels['org.label-schema.version'] = \
567 self.repo_d.manifest_branch
568 self.labels['org.label-schema.vcs-ref'] = \
569 self.repo_d.head_commit
570 self.labels['org.opencord.vcs-commit-date'] = \
571 self.repo_d.head_commit_t
572 else:
573 self.labels['org.label-schema.version'] = "dirty"
574 self.labels['org.label-schema.vcs-ref'] = ""
575
576 # include component labels if present
577 if self.components is not None:
578 self.labels.update(self.component_labels())
579
580 def create_tags(self):
581 """ Create docker tags as needed """
582
583 if self.buildable():
584 LOG.debug("Creating tags for image: %s" % self.name)
585
586 # if clean and parents clean, add tags for branch/commit
587 if self.parent_clean():
588 if build_tag not in self.tags:
589 self.tags.append(build_tag)
590
591 commit_tag = self.repo_d.head_commit
592 if commit_tag not in self.tags:
593 self.tags.append(commit_tag)
594
595 # pulling is done via raw_name, set tag to commit
596 self.raw_name = "%s:%s" % (self.name, commit_tag)
597
598 LOG.debug("All tags: %s" %
599 ", ".join(self.tags))
600
601 def _find_parent_name(self):
602 """ set self.parent_name using Dockerfile FROM line """
603
604 if self.buildable():
605 # read contents of Dockerfile into df
606 with open(self.dockerfile_abspath()) as dfh:
607 df = dfh.read()
608
609 # find FROM line to determine image parent
610 frompatt = re.compile(r'^FROM\s+(.*)$', re.MULTILINE)
611 fromline = re.search(frompatt, df)
612
613 self.parent_name = fromline.group(1) # may have tag
614
615 def dockerfile_abspath(self):
616 """ returns absolute path to Dockerfile for this image """
617
618 if self.buildable():
619 return os.path.join(self.repo_d.abspath(),
620 self.path, self.dockerfile)
621 else:
622 return None
623
624 def dockerfile_rel_path(self):
625 """ returns the path relative to the context of the Dockerfile """
626
627 if self.buildable():
628 if self.context is ".":
629 return self.dockerfile
630 else:
631 return os.path.normpath(os.path.join(self.path,
632 self.dockerfile))
633 else:
634 return None
635
636 def context_tarball(self):
637 """ returns a filehandle to a tarball (tempfile) for the image """
638
639 if self.buildable():
640
641 context_path = os.path.normpath(
642 os.path.join(self.repo_d.abspath(),
643 self.path, self.context))
644
645 LOG.info("Creating context tarball of path: %s" % context_path)
646
647 t_fh = tempfile.NamedTemporaryFile()
648 t = tarfile.open(mode='w', fileobj=t_fh, dereference=True)
649
650 # exclude files in this list
651 exclusion_list = ['.git']
652
653 # see docker-py source for context
654 for path in sorted(
655 DockerUtils.exclude_paths(context_path, exclusion_list)):
656 t.add(os.path.join(context_path, path),
657 arcname=path,
658 recursive=False)
659
660 # add sub-components to tarball if required
661 if self.components is not None:
662 for component in self.components:
663 c_ctx_p = os.path.normpath(
664 os.path.join(component['repo_d'].abspath(),
665 component['path']))
666
667 LOG.info("Adding component %s at context %s" %
668 (component['repo_name'], c_ctx_p))
669
670 # walk component source path
671 for path in sorted(
672 DockerUtils.exclude_paths(c_ctx_p, exclusion_list)):
673
674 # path to where to put files in the archive
675 cf_dest = os.path.normpath(
676 os.path.join(component['dest'], path))
677
678 t.add(os.path.join(c_ctx_p, path),
679 arcname=cf_dest,
680 recursive=False)
681
682 # t.list() # prints all files in tarball
683 t.close()
684 t_fh.seek(0)
685 return t_fh
686
687 else:
688 return None
689
690 def buildargs(self):
691 """ returns array of labels in docker buildargs compliant format """
692 ba_a = {}
693
694 for label_k in self.labels:
695 ba_re = re.compile(r'\W') # non alpha/num/_ chars
696 ba_label = ba_re.sub('_', label_k)
697 ba_a[ba_label] = self.labels[label_k]
698
699 return ba_a
700
701
702class DockerBuilder():
703
704 def __init__(self, repo_manifest):
705
706 global buildable_images
707 global pull_only_images
708
709 self.rm = repo_manifest
710 self.dc = None # Docker Client object
711
712 self.images = []
713
714 # arrays of images, used for write_actions
715 self.all = []
716 self.preexisting = []
717 self.obsolete = []
718 self.pulled = []
719 self.failed_pull = []
720 self.obsolete_pull = []
721 self.built = []
722 self.failed_build = []
723
724 # create dict of images, setting defaults
725 for image in buildable_images:
726
727 repo_d = self.rm.get_repo(image['repo'])
728
729 if "components" in image:
730 components = []
731
732 for component in image['components']:
733 comp = {}
734 comp['repo_name'] = component['repo']
735 comp['repo_d'] = self.rm.get_repo(component['repo'])
736 comp['dest'] = component['dest']
737 comp['path'] = component.get('path', '.')
738 components.append(comp)
739 else:
740 components = None
741
742 # set the full name in case this is pulled
743 full_name = "%s:%s" % (image['name'], build_tag)
744
745 img_o = DockerImage(full_name, image['repo'], repo_d,
746 image.get('path', '.'),
747 image.get('context', '.'),
748 image.get('dockerfile', 'Dockerfile'),
749 components=components)
750
751 self.images.append(img_o)
752
753 # add misc images
754 for misc_image in pull_only_images:
755 img_o = DockerImage(misc_image)
756 self.images.append(img_o)
757
758 if not args.dry_run:
759 self._docker_connect()
760
761 self.create_dependency()
762 self.find_preexisting()
763
764 if args.graph is not None:
765 self.dependency_graph(args.graph)
766
767 self.process_images()
768
769 if args.actions_taken is not None:
770 self.write_actions_file(args.actions_taken)
771
772 def _docker_connect(self):
773 """ Connect to docker daemon """
774
775 self.dc = DockerClient()
776
777 if self.dc.ping():
778 LOG.debug("Docker server is responding")
779 else:
780 LOG.error("Unable to ping docker server")
781 sys.exit(1)
782
783 def find_preexisting(self):
784 """ find images that already exist in Docker and mark """
785
786 if self.dc:
787 LOG.debug("Evaluating already built/fetched Docker images")
788
789 # get list of images from docker
790 pe_images = self.dc.images()
791
792 for pe_image in pe_images:
793 raw_tags = pe_image['RepoTags']
794
795 self.all.append({
796 'id': pe_image['Id'],
797 'tags': raw_tags,
798 })
799
800 # ignoring all <none>:<none> images, reasonable?
801 if raw_tags and "<none>:<none>" not in raw_tags:
802 LOG.debug(" Preexisting Image - ID: %s, tags: %s" %
803 (pe_image['Id'], ",".join(raw_tags)))
804
805 image = self.find_image(raw_tags[0])
806
807 if image is not None:
808 if image.compare_labels(pe_image['Labels']):
809 LOG.debug(" Image %s has up-to-date labels" %
810 pe_image['Id'])
811
812 self.preexisting.append({
813 'id': pe_image['Id'],
814 'tags': raw_tags,
815 })
816
817 image.image_id = pe_image['Id']
818 image.status = DI_EXISTS
819
820 else:
821 LOG.debug(" Image %s has obsolete labels" %
822 pe_image['Id'])
823
824 self.obsolete.append({
825 'id': pe_image['Id'],
826 'tags': raw_tags,
827 })
828
829 def find_image(self, image_name):
830 """ return image object matching name """
831 LOG.debug("attempting to find image for: %s" % image_name)
832
833 for image in self.images:
834 if image.same_name(image_name):
835 return image
836 return None
837
838 def create_dependency(self):
839 """ set parent/child links for images """
840
841 # list of all parent image names, with dupes
842 parents_with_dupes = [img.parent_name for img in self.images
843 if img.parent_name is not None]
844
845 # remove duplicates
846 parents = list(set(parents_with_dupes))
847
848 LOG.info("All parent images: %s" % ", ".join(parents))
849
850 # list of "external parents", ones not built internally
851 external_parents = []
852
853 for parent_name in parents:
854 LOG.debug("Evaluating parent image: %s" % parent_name)
855 internal_parent = False
856
857 # match on p_name, without tag
858 (p_name, p_tag) = split_name(parent_name)
859
860 for image in self.images:
861 if image.same_name(p_name): # internal image is a parent
862 internal_parent = True
863 LOG.debug(" Internal parent: %s" % image.name)
864 break
865
866 if not internal_parent: # parent is external
867 LOG.debug(" External parent: %s" % parent_name)
868 external_parents.append(parent_name)
869
870 # add unique external parents to image list
871 for e_p_name in set(external_parents):
872 LOG.debug(" Creating external parent image object: %s" % e_p_name)
873 img_o = DockerImage(e_p_name)
874 self.images.append(img_o)
875
876 # now that all images (including parents) are in list, associate them
877 for image in filter(lambda img: img.parent_name is not None,
878 self.images):
879
880 LOG.debug("Associating image: %s" % image.name)
881
882 parent = self.find_image(image.parent_name)
883 image.parent = parent
884
885 if parent is not None:
886 LOG.debug(" internal image '%s' is parent of '%s'" %
887 (parent.name, image.name))
888 parent.children.append(image)
889
890 else:
891 LOG.debug(" external image '%s' is parent of '%s'" %
892 (image.parent_name, image.name))
893
894 # loop again now that parents are linked to create labels
895 for image in self.images:
896 image.create_labels()
897 image.create_tags()
898
899 # if image has parent, get labels from parent(s)
900 if image.parent is not None:
901 LOG.debug("Adding parent labels from %s to child %s" %
902 (image.parent.name, image.name))
903
904 # don't create component labels for same repo as image
905 repo_list = [image.repo_name]
906 image.labels.update(image.parent.child_labels(repo_list))
907
908 def dependency_graph(self, graph_fn):
909 """ save a DOT dependency graph to a file """
910
911 graph_fn_abs = os.path.abspath(graph_fn)
912
913 LOG.info("Saving DOT dependency graph to: %s" % graph_fn_abs)
914
915 try:
916 import graphviz
917 except ImportError:
918 LOG.error('graphviz pip module not found')
919 raise
920
921 dg = graphviz.Digraph(comment='Image Dependency Graph',
922 graph_attr={'rankdir': 'LR'})
923
924 component_nodes = []
925
926 # Use raw names, so they match with what's in Dockerfiles
927 # delete colons as python graphviz module breaks with them
928 for image in self.images:
929 name_g = image.raw_name.replace(':', '\n')
930 dg.node(name_g)
931
932 if image.parent is not None:
933 name_p = image.parent.raw_name.replace(':', '\n')
934 dg.edge(name_p, name_g)
935
936 if image.components is not None:
937 for component in image.components:
938 name_c = "component - %s" % component['repo_name']
939 if name_c not in component_nodes:
940 dg.node(name_c)
941 component_nodes.append(name_c)
942 dg.edge(name_c, name_g, "", {'style': 'dashed'})
943
944 with open(graph_fn_abs, 'w') as g_fh:
945 g_fh.write(dg.source)
946
947 def write_actions_file(self, actions_fn):
948
949 actions_fn_abs = os.path.abspath(actions_fn)
950
951 LOG.info("Saving actions as YAML to: %s" % actions_fn_abs)
952
953 actions = {
954 "ib_pulled": self.pulled,
955 "ib_built": self.built,
956 "ib_preexisting_images": self.preexisting,
957 "ib_obsolete_images": self.obsolete,
958 "ib_failed_pull": self.failed_pull,
959 "ib_obsolete_pull": self.obsolete_pull,
960 "ib_failed_build": self.failed_build,
961 }
962
963 with open(actions_fn_abs, 'w') as a_fh:
964 yaml.safe_dump(actions, a_fh)
965 LOG.debug(yaml.safe_dump(actions))
966
967 def process_images(self):
968 """ determine whether to build/fetch images """
969
970 # upstream images (have no parents), must be fetched
971 must_fetch_a = filter(lambda img: img.parent is None, self.images)
972
973 for image in must_fetch_a:
974 if image.status is not DI_EXISTS:
975 image.status = DI_FETCH
976
977 # images that can be built or fetched (have parents)
978 b_or_f_a = filter(lambda img: img.parent is not None, self.images)
979
980 for image in b_or_f_a:
981 if not image.parent_clean():
982 # must be built if not clean
983 image.status = DI_BUILD
984 elif image.status is not DI_EXISTS:
985 # try to fetch if clean and doesn't exist
986 image.status = DI_FETCH
987 # otherwise, image is clean and exists (image.status == DI_EXISTS)
988
989 c_and_e_a = filter(lambda img: img.status is DI_EXISTS, self.images)
990 LOG.info("Preexisting and clean images: %s" %
991 ", ".join(c.name for c in c_and_e_a))
992
993 upstream_a = filter(lambda img: (img.status is DI_FETCH and
994 img.parent is None), self.images)
995 LOG.info("Upstream images that must be fetched: %s" %
996 ", ".join(u.raw_name for u in upstream_a))
997
998 fetch_a = filter(lambda img: (img.status is DI_FETCH and
999 img.parent is not None), self.images)
1000 LOG.info("Clean, buildable images to attempt to fetch: %s" %
1001 ", ".join(f.raw_name for f in fetch_a))
1002
1003 build_a = filter(lambda img: img.status is DI_BUILD, self.images)
1004 LOG.info("Buildable images, due to unclean context or parents: %s" %
1005 ", ".join(b.raw_name for b in build_a))
1006
1007 # OK to fetch upstream in any case as they should reduce number of
1008 # layers pulled/built later
1009
1010 for image in upstream_a:
1011 if not self._fetch_image(image):
1012 LOG.info("Unable to fetch upstream image: %s" % image.raw_name)
1013 # FIXME: fail if the upstream image can't be fetched ?
1014
1015 fetch_sort = sorted(fetch_a, key=(lambda img: len(img.children)),
1016 reverse=True)
1017
1018 for image in fetch_sort:
1019 if not self._fetch_image(image):
1020 # if didn't fetch, build
1021 image.status = DI_BUILD
1022
1023 while True:
1024 buildable_images = self.get_buildable()
1025 if buildable_images:
1026 for image in buildable_images:
1027 self._build_image(image)
1028 else:
1029 LOG.debug("No more images to build, ending build loop")
1030 break
1031
1032 def get_buildable(self):
1033 """ Returns list of images that can be built"""
1034
1035 buildable = []
1036
1037 for image in filter(lambda img: img.status is DI_BUILD, self.images):
1038 if image.parent.status is DI_EXISTS:
1039 buildable.append(image)
1040
1041 LOG.debug("Buildable images: %s" %
1042 ', '.join(image.name for image in buildable))
1043
1044 return buildable
1045
1046 def tag_image(self, image):
1047 """ Applies tags to an image """
1048
1049 for tag in image.tags:
1050
1051 LOG.info("Tagging id: '%s', repo: '%s', tag: '%s'" %
1052 (image.image_id, image.name, tag))
1053
1054 if self.dc is not None:
1055 self.dc.tag(image.image_id, image.name, tag=tag)
1056
1057 def _fetch_image(self, image):
1058
1059 LOG.info("Attempting to fetch docker image: %s" % image.raw_name)
1060
1061 if self.dc is not None:
1062 try:
1063 for stat_json in self.dc.pull(image.raw_name,
1064 stream=True):
1065
1066 # sometimes Docker's JSON is dirty, per:
1067 # https://github.com/docker/docker-py/pull/1081/
1068 stat_s = stat_json.strip()
1069 stat_list = stat_s.split("\r\n")
1070
1071 for s_j in stat_list:
1072 stat_d = json.loads(s_j)
1073
1074 if 'stream' in stat_d:
1075 for stat_l in stat_d['stream'].split('\n'):
1076 LOG.debug(stat_l)
1077
1078 if 'status' in stat_d:
1079 for stat_l in stat_d['status'].split('\n'):
1080 noisy = ["Extracting", "Downloading",
1081 "Waiting", "Download complete",
1082 "Pulling fs layer", "Pull complete",
1083 "Verifying Checksum",
1084 "Already exists"]
1085 if stat_l in noisy:
1086 LOG.debug(stat_l)
1087 else:
1088 LOG.info(stat_l)
1089
1090 if 'error' in stat_d:
1091 LOG.error(stat_d['error'])
1092 sys.exit(1)
1093
1094 except:
1095 LOG.exception("Error pulling docker image")
1096
1097 self.failed_pull.append({
1098 "tags": [image.raw_name, ],
1099 })
1100
1101 return False
1102
1103 # obtain the image_id by inspecting the pulled image. Seems unusual
1104 # that the Docker API `pull` method doesn't provide it when the
1105 # `build` method does
1106 pulled_image = self.dc.inspect_image(image.raw_name)
1107
1108 # check to make sure that image that was downloaded has the labels
1109 # that we expect it to have, otherwise return false, trigger build
1110 if not image.compare_labels(
1111 pulled_image['ContainerConfig']['Labels']):
1112 LOG.info("Tried fetching image %s, but labels didn't match" %
1113 image.raw_name)
1114
1115 self.obsolete_pull.append({
1116 "id": pulled_image['Id'],
1117 "tags": pulled_image['RepoTags'],
1118 })
1119 return False
1120
1121 image.image_id = pulled_image['Id']
1122 LOG.info("Fetched image %s, id: %s" %
1123 (image.raw_name, image.image_id))
1124
1125 self.pulled.append({
1126 "id": pulled_image['Id'],
1127 "tags": pulled_image['RepoTags'],
1128 })
1129
1130 self.tag_image(image)
1131 image.status = DI_EXISTS
1132 return True
1133
1134 def _build_image(self, image):
1135
1136 LOG.info("Building docker image for %s" % image.raw_name)
1137
1138 if self.dc is not None:
1139
1140 build_tag = "%s:%s" % (image.name, image.tags[0])
1141
1142 buildargs = image.buildargs()
1143 context_tar = image.context_tarball()
1144 dockerfile = image.dockerfile_rel_path()
1145
1146 for key, val in buildargs.iteritems():
1147 LOG.debug("Buildarg - %s : %s" % (key, val))
1148
1149 bl_path = ""
1150 start_time = datetime.datetime.utcnow()
1151
1152 if(args.build_log_dir):
1153 bl_name = "%s_%s" % (start_time.strftime("%Y%m%dT%H%M%SZ"),
1154 re.sub(r'\W', '_', image.name))
1155 bl_path = os.path.abspath(
1156 os.path.join(args.build_log_dir, bl_name))
1157
1158 LOG.info("Build log: %s" % bl_path)
1159 bl_fh = open(bl_path, 'w+', 0) # 0 = unbuffered writes
1160 else:
1161 bl_fh = None
1162
1163 try:
1164 LOG.info("Building image: %s" % image)
1165
1166 for stat_d in self.dc.build(tag=build_tag,
1167 buildargs=buildargs,
1168 custom_context=True,
1169 fileobj=context_tar,
1170 dockerfile=dockerfile,
1171 rm=True,
1172 forcerm=True,
1173 pull=False,
1174 stream=True,
1175 decode=True):
1176
1177 if 'stream' in stat_d:
1178
1179 if bl_fh:
1180 bl_fh.write(stat_d['stream'].encode('utf-8'))
1181
1182 for stat_l in stat_d['stream'].split('\n'):
1183 if(stat_l):
1184 LOG.debug(stat_l)
1185 if stat_d['stream'].startswith("Successfully built "):
1186 siid = stat_d['stream'].split(' ')[2]
1187 short_image_id = siid.strip()
1188 LOG.debug("Short Image ID: %s" % short_image_id)
1189
1190 if 'status' in stat_d:
1191 for stat_l in stat_d['status'].split('\n'):
1192 if(stat_l):
1193 LOG.info(stat_l)
1194
1195 if 'error' in stat_d:
1196 LOG.error(stat_d['error'])
1197 image.status = DI_ERROR
1198 sys.exit(1)
1199
1200 except:
1201 LOG.exception("Error building docker image")
1202
1203 self.failed_build.append({
1204 "tags": [build_tag, ],
1205 })
1206
1207 return
1208
1209 finally:
1210 if(bl_fh):
1211 bl_fh.close()
1212
1213 # the image ID given by output isn't the full SHA256 id, so find
1214 # and set it to the full one
1215 built_image = self.dc.inspect_image(short_image_id)
1216 image.image_id = built_image['Id']
1217
1218 end_time = datetime.datetime.utcnow()
1219 duration = end_time - start_time # duration is a timedelta
1220
1221 LOG.info("Built Image: %s, duration: %s, id: %s" %
1222 (image.name, duration, image.image_id))
1223
1224 self.built.append({
1225 "id": image.image_id,
1226 "tags": [build_tag, ],
1227 "push_name": image.raw_name,
1228 "build_log": bl_path,
1229 "duration": duration.total_seconds(),
1230 })
1231
1232 self.tag_image(image)
1233 image.status = DI_EXISTS
1234
1235
1236if __name__ == "__main__":
1237 parse_args()
1238 load_config()
1239
1240 # only include docker module if not a dry run
1241 if not args.dry_run:
1242 try:
1243 from distutils.version import LooseVersion
1244 from docker import __version__ as docker_version
1245 if LooseVersion(docker_version) >= LooseVersion('2.0.0'):
1246 from docker import APIClient as DockerClient
1247 from docker import utils as DockerUtils
1248 else:
1249 from docker import Client as DockerClient
1250 from docker import utils as DockerUtils
1251 except ImportError:
1252 LOG.error("Unable to load python docker module (dry run?)")
1253 sys.exit(1)
1254
1255 rm = RepoManifest()
1256 db = DockerBuilder(rm)