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