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