1#!/usr/bin/env python2 2# 3# Docker controlling module 4# 5# Copyright (c) 2016 Red Hat Inc. 6# 7# Authors: 8# Fam Zheng <famz@redhat.com> 9# 10# This work is licensed under the terms of the GNU GPL, version 2 11# or (at your option) any later version. See the COPYING file in 12# the top-level directory. 13 14import os 15import sys 16import subprocess 17import json 18import hashlib 19import atexit 20import uuid 21import argparse 22import tempfile 23import re 24from tarfile import TarFile, TarInfo 25from StringIO import StringIO 26from shutil import copy, rmtree 27 28 29DEVNULL = open(os.devnull, 'wb') 30 31 32def _text_checksum(text): 33 """Calculate a digest string unique to the text content""" 34 return hashlib.sha1(text).hexdigest() 35 36def _guess_docker_command(): 37 """ Guess a working docker command or raise exception if not found""" 38 commands = [["docker"], ["sudo", "-n", "docker"]] 39 for cmd in commands: 40 if subprocess.call(cmd + ["images"], 41 stdout=DEVNULL, stderr=DEVNULL) == 0: 42 return cmd 43 commands_txt = "\n".join([" " + " ".join(x) for x in commands]) 44 raise Exception("Cannot find working docker command. Tried:\n%s" % \ 45 commands_txt) 46 47def _copy_with_mkdir(src, root_dir, sub_path): 48 """Copy src into root_dir, creating sub_path as needed.""" 49 dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path)) 50 try: 51 os.makedirs(dest_dir) 52 except OSError: 53 # we can safely ignore already created directories 54 pass 55 56 dest_file = "%s/%s" % (dest_dir, os.path.basename(src)) 57 copy(src, dest_file) 58 59 60def _get_so_libs(executable): 61 """Return a list of libraries associated with an executable. 62 63 The paths may be symbolic links which would need to be resolved to 64 ensure theright data is copied.""" 65 66 libs = [] 67 ldd_re = re.compile(r"(/.*/)(\S*)") 68 try: 69 ldd_output = subprocess.check_output(["ldd", executable]) 70 for line in ldd_output.split("\n"): 71 search = ldd_re.search(line) 72 if search and len(search.groups()) == 2: 73 so_path = search.groups()[0] 74 so_lib = search.groups()[1] 75 libs.append("%s/%s" % (so_path, so_lib)) 76 except subprocess.CalledProcessError: 77 print "%s had no associated libraries (static build?)" % (executable) 78 79 return libs 80 81def _copy_binary_with_libs(src, dest_dir): 82 """Copy a binary executable and all its dependant libraries. 83 84 This does rely on the host file-system being fairly multi-arch 85 aware so the file don't clash with the guests layout.""" 86 87 _copy_with_mkdir(src, dest_dir, "/usr/bin") 88 89 libs = _get_so_libs(src) 90 if libs: 91 for l in libs: 92 so_path = os.path.dirname(l) 93 _copy_with_mkdir(l , dest_dir, so_path) 94 95class Docker(object): 96 """ Running Docker commands """ 97 def __init__(self): 98 self._command = _guess_docker_command() 99 self._instances = [] 100 atexit.register(self._kill_instances) 101 102 def _do(self, cmd, quiet=True, infile=None, **kwargs): 103 if quiet: 104 kwargs["stdout"] = DEVNULL 105 if infile: 106 kwargs["stdin"] = infile 107 return subprocess.call(self._command + cmd, **kwargs) 108 109 def _do_kill_instances(self, only_known, only_active=True): 110 cmd = ["ps", "-q"] 111 if not only_active: 112 cmd.append("-a") 113 for i in self._output(cmd).split(): 114 resp = self._output(["inspect", i]) 115 labels = json.loads(resp)[0]["Config"]["Labels"] 116 active = json.loads(resp)[0]["State"]["Running"] 117 if not labels: 118 continue 119 instance_uuid = labels.get("com.qemu.instance.uuid", None) 120 if not instance_uuid: 121 continue 122 if only_known and instance_uuid not in self._instances: 123 continue 124 print "Terminating", i 125 if active: 126 self._do(["kill", i]) 127 self._do(["rm", i]) 128 129 def clean(self): 130 self._do_kill_instances(False, False) 131 return 0 132 133 def _kill_instances(self): 134 return self._do_kill_instances(True) 135 136 def _output(self, cmd, **kwargs): 137 return subprocess.check_output(self._command + cmd, 138 stderr=subprocess.STDOUT, 139 **kwargs) 140 141 def get_image_dockerfile_checksum(self, tag): 142 resp = self._output(["inspect", tag]) 143 labels = json.loads(resp)[0]["Config"].get("Labels", {}) 144 return labels.get("com.qemu.dockerfile-checksum", "") 145 146 def build_image(self, tag, docker_dir, dockerfile, quiet=True, argv=None): 147 if argv == None: 148 argv = [] 149 150 tmp_df = tempfile.NamedTemporaryFile(dir=docker_dir, suffix=".docker") 151 tmp_df.write(dockerfile) 152 153 tmp_df.write("\n") 154 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" % 155 _text_checksum(dockerfile)) 156 tmp_df.flush() 157 158 self._do(["build", "-t", tag, "-f", tmp_df.name] + argv + \ 159 [docker_dir], 160 quiet=quiet) 161 162 def update_image(self, tag, tarball, quiet=True): 163 "Update a tagged image using " 164 165 self._do(["build", "-t", tag, "-"], quiet=quiet, infile=tarball) 166 167 def image_matches_dockerfile(self, tag, dockerfile): 168 try: 169 checksum = self.get_image_dockerfile_checksum(tag) 170 except Exception: 171 return False 172 return checksum == _text_checksum(dockerfile) 173 174 def run(self, cmd, keep, quiet): 175 label = uuid.uuid1().hex 176 if not keep: 177 self._instances.append(label) 178 ret = self._do(["run", "--label", 179 "com.qemu.instance.uuid=" + label] + cmd, 180 quiet=quiet) 181 if not keep: 182 self._instances.remove(label) 183 return ret 184 185 def command(self, cmd, argv, quiet): 186 return self._do([cmd] + argv, quiet=quiet) 187 188class SubCommand(object): 189 """A SubCommand template base class""" 190 name = None # Subcommand name 191 def shared_args(self, parser): 192 parser.add_argument("--quiet", action="store_true", 193 help="Run quietly unless an error occured") 194 195 def args(self, parser): 196 """Setup argument parser""" 197 pass 198 def run(self, args, argv): 199 """Run command. 200 args: parsed argument by argument parser. 201 argv: remaining arguments from sys.argv. 202 """ 203 pass 204 205class RunCommand(SubCommand): 206 """Invoke docker run and take care of cleaning up""" 207 name = "run" 208 def args(self, parser): 209 parser.add_argument("--keep", action="store_true", 210 help="Don't remove image when command completes") 211 def run(self, args, argv): 212 return Docker().run(argv, args.keep, quiet=args.quiet) 213 214class BuildCommand(SubCommand): 215 """ Build docker image out of a dockerfile. Arguments: <tag> <dockerfile>""" 216 name = "build" 217 def args(self, parser): 218 parser.add_argument("--include-executable", "-e", 219 help="""Specify a binary that will be copied to the 220 container together with all its dependent 221 libraries""") 222 parser.add_argument("tag", 223 help="Image Tag") 224 parser.add_argument("dockerfile", 225 help="Dockerfile name") 226 227 def run(self, args, argv): 228 dockerfile = open(args.dockerfile, "rb").read() 229 tag = args.tag 230 231 dkr = Docker() 232 if dkr.image_matches_dockerfile(tag, dockerfile): 233 if not args.quiet: 234 print "Image is up to date." 235 else: 236 # Create a docker context directory for the build 237 docker_dir = tempfile.mkdtemp(prefix="docker_build") 238 239 # Is there a .pre file to run in the build context? 240 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre" 241 if os.path.exists(docker_pre): 242 stdout = DEVNULL if args.quiet else None 243 rc = subprocess.call(os.path.realpath(docker_pre), 244 cwd=docker_dir, stdout=stdout) 245 if rc == 3: 246 print "Skip" 247 return 0 248 elif rc != 0: 249 print "%s exited with code %d" % (docker_pre, rc) 250 return 1 251 252 # Do we include a extra binary? 253 if args.include_executable: 254 _copy_binary_with_libs(args.include_executable, 255 docker_dir) 256 257 dkr.build_image(tag, docker_dir, dockerfile, 258 quiet=args.quiet, argv=argv) 259 260 rmtree(docker_dir) 261 262 return 0 263 264class UpdateCommand(SubCommand): 265 """ Update a docker image with new executables. Arguments: <tag> <executable>""" 266 name = "update" 267 def args(self, parser): 268 parser.add_argument("tag", 269 help="Image Tag") 270 parser.add_argument("executable", 271 help="Executable to copy") 272 273 def run(self, args, argv): 274 # Create a temporary tarball with our whole build context and 275 # dockerfile for the update 276 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz") 277 tmp_tar = TarFile(fileobj=tmp, mode='w') 278 279 # Add the executable to the tarball 280 bn = os.path.basename(args.executable) 281 ff = "/usr/bin/%s" % bn 282 tmp_tar.add(args.executable, arcname=ff) 283 284 # Add any associated libraries 285 libs = _get_so_libs(args.executable) 286 if libs: 287 for l in libs: 288 tmp_tar.add(os.path.realpath(l), arcname=l) 289 290 # Create a Docker buildfile 291 df = StringIO() 292 df.write("FROM %s\n" % args.tag) 293 df.write("ADD . /\n") 294 df.seek(0) 295 296 df_tar = TarInfo(name="Dockerfile") 297 df_tar.size = len(df.buf) 298 tmp_tar.addfile(df_tar, fileobj=df) 299 300 tmp_tar.close() 301 302 # reset the file pointers 303 tmp.flush() 304 tmp.seek(0) 305 306 # Run the build with our tarball context 307 dkr = Docker() 308 dkr.update_image(args.tag, tmp, quiet=args.quiet) 309 310 return 0 311 312class CleanCommand(SubCommand): 313 """Clean up docker instances""" 314 name = "clean" 315 def run(self, args, argv): 316 Docker().clean() 317 return 0 318 319class ImagesCommand(SubCommand): 320 """Run "docker images" command""" 321 name = "images" 322 def run(self, args, argv): 323 return Docker().command("images", argv, args.quiet) 324 325def main(): 326 parser = argparse.ArgumentParser(description="A Docker helper", 327 usage="%s <subcommand> ..." % os.path.basename(sys.argv[0])) 328 subparsers = parser.add_subparsers(title="subcommands", help=None) 329 for cls in SubCommand.__subclasses__(): 330 cmd = cls() 331 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__) 332 cmd.shared_args(subp) 333 cmd.args(subp) 334 subp.set_defaults(cmdobj=cmd) 335 args, argv = parser.parse_known_args() 336 return args.cmdobj.run(args, argv) 337 338if __name__ == "__main__": 339 sys.exit(main()) 340