blob: 20422302393b03864bdd0e4249362688dbd0ce91 [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 = []
Matteo Scandoloa3213822018-05-21 18:17:28 -0700820
821 # create dict of images, setting defaults
822 for image in buildable_images:
823
824 repo_d = self.rm.get_repo(image['repo'])
825
826 if "components" in image:
827 components = []
828
829 for component in image['components']:
830 comp = {}
831 comp['repo_name'] = component['repo']
832 comp['repo_d'] = self.rm.get_repo(component['repo'])
833 comp['dest'] = component['dest']
834 comp['path'] = component.get('path', '.')
835 components.append(comp)
836 else:
837 components = None
838
839 # set the full name in case this is pulled
840 full_name = "%s:%s" % (image['name'], build_tag)
841
842 img_o = DockerImage(full_name, image['repo'], repo_d,
843 image.get('path', '.'),
844 image.get('context', '.'),
845 image.get('dockerfile', 'Dockerfile'),
846 components=components)
847
848 self.images.append(img_o)
849
850 # add misc images
851 for misc_image in pull_only_images:
852 img_o = DockerImage(misc_image)
853 self.images.append(img_o)
854
855 if not args.dry_run:
856 self._docker_connect()
857
858 self.create_dependency()
859
860 if not args.build: # if forcing build, don't use preexisting
861 self.find_preexisting()
862
863 if args.graph is not None:
864 self.dependency_graph(args.graph)
865
866 self.process_images()
867
868 if args.actions_taken is not None:
869 self.write_actions_file(args.actions_taken)
870
871 def _docker_connect(self):
872 """ Connect to docker daemon """
873
874 try:
875 # get a "high level" Docker object with conf from the environment
876 hl_dc = docker.from_env()
877 # use the low level APIClient (same as the 1.x API)
878 self.dc = hl_dc.api
879 except requests.ConnectionError:
880 LOG.debug("Docker connection not available")
881 sys.exit(1)
882
883 if self.dc.ping():
884 LOG.debug("Docker server is responding")
885 else:
886 LOG.error("Unable to ping docker server")
887 sys.exit(1)
888
889 def find_preexisting(self):
890 """ find images that already exist in Docker and mark """
891
892 if self.dc:
893 LOG.debug("Evaluating already built/fetched Docker images")
894
895 # get list of images from docker
896 pe_images = self.dc.images()
897
898 for pe_image in pe_images:
899 raw_tags = pe_image['RepoTags']
900
901 if raw_tags:
902 LOG.info("Preexisting Image - ID: %s, tags: %s" %
903 (pe_image['Id'], ",".join(raw_tags)))
904
905 has_build_tag = False
906 for tag in raw_tags:
907 if build_tag in tag:
908 LOG.debug(" image has build_tag: %s" % build_tag)
909 has_build_tag = True
910
911 base_name = raw_tags[0].split(":")[0]
912 image = self.find_image(base_name)
913
914 # only evaluate images in the list of desired images
915 if image is not None:
916
917 good_labels = image.compare_labels(pe_image['Labels'])
918
919 if good_labels:
920 if has_build_tag:
921 LOG.info(" Image %s has up-to-date labels and"
922 " build_tag" % pe_image['Id'])
923 else:
924 LOG.info(" Image %s has up-to-date labels but"
925 " missing build_tag. Tagging image"
926 " with build_tag: %s" %
927 (pe_image['Id'], build_tag))
928
929 self.dc.tag(pe_image['Id'], image.name,
930 tag=build_tag)
931
932 self.preexisting.append({
933 'id': pe_image['Id'],
934 'tags': raw_tags,
935 'base': image.name.split(":")[0],
936 })
937
938 image.image_id = pe_image['Id']
939 image.status = DI_EXISTS
940
941 else: # doesn't have good labels
942
943 # if it has a build_tag, and a good image hasn't
944 # already been tagged
945 if has_build_tag and (image.status != DI_EXISTS):
946 LOG.info(" Image %s has obsolete labels and"
947 " build_tag, remove" % pe_image['Id'])
948
949 # remove build_tag from image
950 name_bt = "%s:%s" % (base_name, build_tag)
951 self.dc.remove_image(name_bt, args.force, True)
952
953 else:
954 LOG.info(" Image %s has obsolete labels, lacks"
955 " build_tag, ignore" % pe_image['Id'])
956
957 self.obsolete.append({
958 'id': pe_image['Id'],
959 'tags': raw_tags,
960 })
961
962 def find_image(self, image_name):
963 """ return image object matching name """
964 LOG.debug(" attempting to find image for: %s" % image_name)
965
966 for image in self.images:
967 if image.same_name(image_name):
968 LOG.debug(" found a match: %s" % image.raw_name)
969 return image
Zack Williamsd29d4412018-06-28 09:10:05 -0700970
Matteo Scandoloa3213822018-05-21 18:17:28 -0700971 return None
972
973 def create_dependency(self):
974 """ set parent/child links for images """
975
976 # List of lists of parents images. Done in two steps for clarity
977 lol_of_parents = [img.parent_names for img in self.images
978 if img.parent_names is not []]
979
980 # flat list of all parent image names, with dupes
981 parents_with_dupes = [parent for parent_sublist in lol_of_parents
982 for parent in parent_sublist]
983
984 # remove duplicates
985 parents = list(set(parents_with_dupes))
986
987 LOG.info("All parent images: %s" % ", ".join(parents))
988
989 # list of "external parents", ones not built internally
990 external_parents = []
991
992 for parent_name in parents:
993 LOG.debug("Evaluating parent image: %s" % parent_name)
994 internal_parent = False
995
Matteo Scandoloa3213822018-05-21 18:17:28 -0700996 for image in self.images:
Zack Williamsd29d4412018-06-28 09:10:05 -0700997 if image.same_name(parent_name): # internal image is a parent
Matteo Scandoloa3213822018-05-21 18:17:28 -0700998 internal_parent = True
999 LOG.debug(" Internal parent: %s" % image.name)
1000 break
1001
1002 if not internal_parent: # parent is external
1003 LOG.debug(" External parent: %s" % parent_name)
1004 external_parents.append(parent_name)
1005
1006 # add unique external parents to image list
1007 for e_p_name in set(external_parents):
1008 LOG.debug(" Creating external parent image object: %s" % e_p_name)
1009 img_o = DockerImage(e_p_name)
1010 self.images.append(img_o)
1011
1012 # now that all images (including parents) are in list, associate them
1013 for image in filter(lambda img: img.parent_names is not [],
1014 self.images):
1015
1016 LOG.debug("Associating image: %s" % image.name)
1017
1018 for parent_name in image.parent_names:
1019
1020 parent = self.find_image(parent_name)
Zack Williamsd29d4412018-06-28 09:10:05 -07001021
Matteo Scandoloa3213822018-05-21 18:17:28 -07001022 if parent is not None:
1023 LOG.debug(" internal image '%s' is parent of '%s'" %
1024 (parent.name, image.name))
Zack Williamsd29d4412018-06-28 09:10:05 -07001025 image.parents.append(parent)
Matteo Scandoloa3213822018-05-21 18:17:28 -07001026 parent.children.append(image)
1027
1028 else:
1029 LOG.debug(" external image '%s' is parent of '%s'" %
Zack Williamsd29d4412018-06-28 09:10:05 -07001030 (parent_name, image.name))
Matteo Scandoloa3213822018-05-21 18:17:28 -07001031
1032 # loop again now that parents are linked to create labels
1033 for image in self.images:
1034 image.create_labels()
1035 image.create_tags()
1036
1037 # if image has parent, get labels from parent(s)
1038 if image.parents is not None:
1039 for parent in image.parents:
1040 LOG.debug("Adding parent labels from %s to child %s" %
1041 (parent.name, image.name))
1042
1043 # don't create component labels for same repo as image
1044 repo_list = [image.repo_name]
1045 image.labels.update(parent.child_labels(repo_list))
1046
1047 def dependency_graph(self, graph_fn):
1048 """ save a DOT dependency graph to a file """
1049
1050 graph_fn_abs = os.path.abspath(graph_fn)
1051
1052 LOG.info("Saving DOT dependency graph to: %s" % graph_fn_abs)
1053
1054 try:
1055 import graphviz
1056 except ImportError:
1057 LOG.error('graphviz pip module not found')
1058 raise
1059
1060 dg = graphviz.Digraph(comment='Image Dependency Graph',
1061 graph_attr={'rankdir': 'LR'})
1062
1063 component_nodes = []
1064
1065 # Use raw names, so they match with what's in Dockerfiles
1066 # delete colons as python graphviz module breaks with them
1067 for image in self.images:
1068 name_g = image.raw_name.replace(':', '\n')
1069 dg.node(name_g)
1070
1071 if image.parents is not None:
1072 for parent in image.parents:
1073 name_p = parent.raw_name.replace(':', '\n')
1074 dg.edge(name_p, name_g)
1075
1076 if image.components is not None:
1077 for component in image.components:
1078 name_c = "component - %s" % component['repo_name']
1079 if name_c not in component_nodes:
1080 dg.node(name_c)
1081 component_nodes.append(name_c)
1082 dg.edge(name_c, name_g, "", {'style': 'dashed'})
1083
1084 with open(graph_fn_abs, 'w') as g_fh:
1085 g_fh.write(dg.source)
1086
1087 def write_actions_file(self, actions_fn):
1088
1089 actions_fn_abs = os.path.abspath(actions_fn)
1090
1091 LOG.info("Saving actions as YAML to: %s" % actions_fn_abs)
1092
1093 actions = {
1094 "ib_pulled": self.pulled,
1095 "ib_built": self.built,
1096 "ib_preexisting_images": self.preexisting,
1097 "ib_obsolete_images": self.obsolete,
1098 "ib_failed_pull": self.failed_pull,
1099 "ib_obsolete_pull": self.obsolete_pull,
Matteo Scandoloa3213822018-05-21 18:17:28 -07001100 }
1101
1102 with open(actions_fn_abs, 'w') as a_fh:
1103 yaml.safe_dump(actions, a_fh)
1104 LOG.debug(yaml.safe_dump(actions))
1105
1106 def process_images(self):
1107
1108 """ determine whether to build/fetch images """
1109 # upstream images (have no parents), must be fetched
1110 must_fetch_a = filter(lambda img: not img.parents, self.images)
1111
1112 for image in must_fetch_a:
1113 if image.status is not DI_EXISTS:
1114 image.status = DI_FETCH
1115
1116 # images that can be built or fetched (have parents)
1117 b_or_f_a = filter(lambda img: img.parents, self.images)
1118
1119 for image in b_or_f_a:
1120 if not image.parents_clean() or args.build:
1121 # must be built if not clean
1122 image.status = DI_BUILD
1123 elif image.status is not DI_EXISTS:
1124 # try to fetch if clean and doesn't exist
1125 image.status = DI_FETCH
1126 # otherwise, image is clean and exists (image.status == DI_EXISTS)
1127
1128 c_and_e_a = filter(lambda img: img.status is DI_EXISTS, self.images)
1129 LOG.info("Preexisting and clean images: %s" %
1130 ", ".join(c.name for c in c_and_e_a))
1131
1132 upstream_a = filter(lambda img: (img.status is DI_FETCH and
1133 not img.parents), self.images)
1134 LOG.info("Upstream images that must be fetched: %s" %
1135 ", ".join(u.raw_name for u in upstream_a))
1136
1137 fetch_a = filter(lambda img: (img.status is DI_FETCH and
1138 img.parents), self.images)
1139 LOG.info("Clean, buildable images to attempt to fetch: %s" %
1140 ", ".join(f.raw_name for f in fetch_a))
1141
1142 build_a = filter(lambda img: img.status is DI_BUILD, self.images)
1143 LOG.info("Buildable images, due to unclean context or parents: %s" %
1144 ", ".join(b.raw_name for b in build_a))
1145
1146 # OK to fetch upstream in any case as they should reduce number of
1147 # layers pulled/built later
1148
1149 for image in upstream_a:
1150 if not self._fetch_image(image):
1151 LOG.error("Unable to fetch upstream image: %s" %
1152 image.raw_name)
1153 sys.exit(1)
1154
1155 # fetch if not forcing the build of all images
1156 if not args.build:
1157 fetch_sort = sorted(fetch_a, key=(lambda img: len(img.children)),
1158 reverse=True)
1159
1160 for image in fetch_sort:
1161 if not self._fetch_image(image):
1162 # if didn't fetch, build
1163 image.status = DI_BUILD
1164
1165 while True:
1166 buildable_images = self.get_buildable()
1167
1168 if buildable_images and args.pull:
1169 LOG.error("Images must be built, but --pull is specified")
1170 exit(1)
1171
1172 if buildable_images:
1173 for image in buildable_images:
1174 self._build_image(image)
1175 else:
1176 LOG.debug("No more images to build, ending build loop")
1177 break
1178
1179 def get_buildable(self):
1180 """ Returns list of images that can be built"""
1181
1182 buildable = []
1183
1184 for image in filter(lambda img: img.status is DI_BUILD, self.images):
1185 for parent in image.parents:
1186 if parent.status is DI_EXISTS:
1187 if image not in buildable: # build once if two parents
1188 buildable.append(image)
1189
1190 LOG.debug("Buildable images: %s" %
1191 ', '.join(image.name for image in buildable))
1192
1193 return buildable
1194
1195 def tag_image(self, image):
1196 """ Applies tags to an image """
1197
1198 for tag in image.tags:
1199
1200 LOG.info("Tagging id: '%s', repo: '%s', tag: '%s'" %
1201 (image.image_id, image.name, tag))
1202
1203 if self.dc is not None:
1204 self.dc.tag(image.image_id, image.name, tag=tag)
1205
1206 def _fetch_image(self, image):
1207
1208 LOG.info("Attempting to fetch docker image: %s" % image.raw_name)
1209
1210 if self.dc is not None:
1211 try:
1212 for stat_json in self.dc.pull(image.raw_name,
1213 stream=True):
1214
1215 # sometimes Docker's JSON is dirty, per:
1216 # https://github.com/docker/docker-py/pull/1081/
1217 stat_s = stat_json.strip()
1218 stat_list = stat_s.split("\r\n")
1219
1220 for s_j in stat_list:
1221 stat_d = json.loads(s_j)
1222
1223 if 'stream' in stat_d:
1224 for stat_l in stat_d['stream'].split('\n'):
1225 LOG.debug(stat_l)
1226
1227 if 'status' in stat_d:
1228 for stat_l in stat_d['status'].split('\n'):
1229 noisy = ["Extracting", "Downloading",
1230 "Waiting", "Download complete",
1231 "Pulling fs layer", "Pull complete",
1232 "Verifying Checksum",
1233 "Already exists"]
1234 if stat_l in noisy:
1235 LOG.debug(stat_l)
1236 else:
1237 LOG.info(stat_l)
1238
1239 if 'error' in stat_d:
1240 LOG.error(stat_d['error'])
1241 sys.exit(1)
1242
1243 except (DockerErrors.NotFound, DockerErrors.ImageNotFound) as e:
1244 LOG.warning("Image could not be pulled: %s" % e)
1245
1246 self.failed_pull.append({
1247 "tags": [image.raw_name, ],
1248 })
1249
1250 if not image.parents:
1251 LOG.error("Pulled image required to build, not available!")
1252 sys.exit(1)
1253
1254 return False
1255
Zack Williamse62442b2018-08-14 16:52:49 -07001256 except BaseException as e:
1257 LOG.exception("Error fetching image: %s" % e)
1258 sys.exit(1)
Matteo Scandoloa3213822018-05-21 18:17:28 -07001259
1260 # obtain the image_id by inspecting the pulled image. Seems unusual
1261 # that the Docker API `pull` method doesn't provide it when the
1262 # `build` method does
1263 pulled_image = self.dc.inspect_image(image.raw_name)
1264
1265 # check to make sure that image that was downloaded has the labels
1266 # that we expect it to have, otherwise return false, trigger build
1267 if not image.compare_labels(
1268 pulled_image['ContainerConfig']['Labels']):
1269 LOG.info("Tried fetching image %s, but labels didn't match" %
1270 image.raw_name)
1271
1272 self.obsolete_pull.append({
1273 "id": pulled_image['Id'],
1274 "tags": pulled_image['RepoTags'],
1275 })
1276 return False
1277
1278 image.image_id = pulled_image['Id']
1279 LOG.info("Fetched image %s, id: %s" %
1280 (image.raw_name, image.image_id))
1281
1282 self.pulled.append({
1283 "id": pulled_image['Id'],
1284 "tags": pulled_image['RepoTags'],
1285 "base": image.name.split(":")[0],
1286 })
1287
1288 self.tag_image(image)
1289 image.status = DI_EXISTS
1290 return True
1291
1292 def _build_image(self, image):
1293
1294 global build_tag
1295
1296 LOG.info("Building docker image for %s" % image.raw_name)
1297
1298 if self.dc is not None:
1299
1300 image_build_tag = "%s:%s" % (image.name, build_tag)
1301
1302 buildargs = image.buildargs()
1303 context_tar = image.context_tarball()
1304 dockerfile = image.dockerfile_rel_path()
1305
1306 for key, val in buildargs.iteritems():
1307 LOG.debug("Buildarg - %s : %s" % (key, val))
1308
1309 bl_path = ""
1310 start_time = datetime.datetime.utcnow()
1311
1312 if(args.build_log_dir):
1313 bl_name = "%s_%s" % (start_time.strftime("%Y%m%dT%H%M%SZ"),
1314 re.sub(r'\W', '_', image.name))
1315 bl_path = os.path.abspath(
1316 os.path.join(args.build_log_dir, bl_name))
1317
1318 LOG.info("Build log: %s" % bl_path)
1319 bl_fh = open(bl_path, 'w+', 0) # 0 = unbuffered writes
1320 else:
1321 bl_fh = None
1322
1323 try:
1324 LOG.info("Building image: %s" % image)
1325
1326 buildparams = dict(
1327 tag=image_build_tag,
1328 buildargs=buildargs,
1329 nocache=args.build,
1330 custom_context=True,
1331 fileobj=context_tar,
1332 dockerfile=dockerfile,
1333 rm=True,
1334 forcerm=True,
1335 pull=False,
1336 decode=True
1337 )
1338
1339 if DOCKER_PY_VERSION == 2:
1340 buildparams['stream'] = True
1341
1342 for stat_d in self.dc.build(**buildparams):
1343
1344 if 'stream' in stat_d:
1345
1346 if bl_fh:
1347 bl_fh.write(stat_d['stream'].encode('utf-8'))
1348
1349 for stat_l in stat_d['stream'].split('\n'):
1350 if(stat_l):
1351 LOG.debug(stat_l)
1352 if stat_d['stream'].startswith("Successfully built "):
1353 siid = stat_d['stream'].split(' ')[2]
1354 short_image_id = siid.strip()
1355 LOG.debug("Short Image ID: %s" % short_image_id)
1356
1357 if 'status' in stat_d:
1358 for stat_l in stat_d['status'].split('\n'):
1359 if(stat_l):
1360 LOG.info(stat_l)
1361
1362 if 'error' in stat_d:
1363 LOG.error(stat_d['error'])
1364 image.status = DI_ERROR
1365 sys.exit(1)
1366
Zack Williamse62442b2018-08-14 16:52:49 -07001367 except DockerErrors as e:
1368 LOG.exception("Error building docker image: %s" % e)
1369 sys.exit(1)
Matteo Scandoloa3213822018-05-21 18:17:28 -07001370
1371 finally:
1372 if(bl_fh):
1373 bl_fh.close()
1374
1375 # the image ID given by output isn't the full SHA256 id, so find
1376 # and set it to the full one
1377 built_image = self.dc.inspect_image(short_image_id)
1378 image.image_id = built_image['Id']
1379
1380 end_time = datetime.datetime.utcnow()
1381 duration = end_time - start_time # duration is a timedelta
1382
1383 LOG.info("Built Image: %s, duration: %s, id: %s" %
1384 (image.name, duration, image.image_id))
1385
1386 self.tag_image(image)
1387
1388 # don't push the build_tag to dockerhub
1389 built_tags = list(image.tags)
1390 built_tags.remove(build_tag)
1391
1392 self.built.append({
1393 "id": image.image_id,
1394 "tags": built_tags,
1395 "build_log": bl_path,
1396 "duration": duration.total_seconds(),
1397 "base": image.name.split(":")[0],
1398 })
1399
1400 image.status = DI_EXISTS
1401
1402
1403if __name__ == "__main__":
1404 parse_args()
1405 load_config()
1406
1407 # only include docker module if not a dry run
1408 if not args.dry_run:
1409 try:
1410 import requests
1411 from distutils.version import LooseVersion
1412 from docker import __version__ as docker_version
1413
1414 # handle the docker-py v1 to v2 API differences
1415 if LooseVersion(docker_version) < LooseVersion('2.0.0'):
1416 LOG.error("Unsupported python docker module - "
1417 "remove docker-py 1.x, install docker 2.x")
1418 sys.exit(1)
1419
1420 DOCKER_PY_VERSION = 2
1421 if LooseVersion(docker_version) >= LooseVersion('3.0.0'):
1422 DOCKER_PY_VERSION = 3
1423
1424 import docker
1425 from docker import utils as DockerUtils
1426 from docker import errors as DockerErrors
1427
1428 except ImportError:
1429 LOG.error("Unable to load python docker module (dry run?)")
1430 sys.exit(1)
1431
1432 rm = RepoManifest()
1433 db = DockerBuilder(rm)