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