3 # Docker controlling module
5 # Copyright (c) 2016 Red Hat Inc.
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.
14 from __future__ import print_function
17 sys.path.append(os.path.join(os.path.dirname(__file__),
18 '..', '..', 'scripts'))
28 from tarfile import TarFile, TarInfo
30 from StringIO import StringIO
32 from io import StringIO
33 from shutil import copy, rmtree
34 from pwd import getpwuid
35 from datetime import datetime,timedelta
38 FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
41 DEVNULL = open(os.devnull, 'wb')
44 def _text_checksum(text):
45 """Calculate a digest string unique to the text content"""
46 return hashlib.sha1(text).hexdigest()
48 def _file_checksum(filename):
49 return _text_checksum(open(filename, 'rb').read())
51 def _guess_docker_command():
52 """ Guess a working docker command or raise exception if not found"""
53 commands = [["docker"], ["sudo", "-n", "docker"]]
56 # docker version will return the client details in stdout
57 # but still report a status of 1 if it can't contact the daemon
58 if subprocess.call(cmd + ["version"],
59 stdout=DEVNULL, stderr=DEVNULL) == 0:
63 commands_txt = "\n".join([" " + " ".join(x) for x in commands])
64 raise Exception("Cannot find working docker command. Tried:\n%s" % \
67 def _copy_with_mkdir(src, root_dir, sub_path='.'):
68 """Copy src into root_dir, creating sub_path as needed."""
69 dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
73 # we can safely ignore already created directories
76 dest_file = "%s/%s" % (dest_dir, os.path.basename(src))
80 def _get_so_libs(executable):
81 """Return a list of libraries associated with an executable.
83 The paths may be symbolic links which would need to be resolved to
84 ensure theright data is copied."""
87 ldd_re = re.compile(r"(/.*/)(\S*)")
89 ldd_output = subprocess.check_output(["ldd", executable])
90 for line in ldd_output.split("\n"):
91 search = ldd_re.search(line)
92 if search and len(search.groups()) == 2:
93 so_path = search.groups()[0]
94 so_lib = search.groups()[1]
95 libs.append("%s/%s" % (so_path, so_lib))
96 except subprocess.CalledProcessError:
97 print("%s had no associated libraries (static build?)" % (executable))
101 def _copy_binary_with_libs(src, dest_dir):
102 """Copy a binary executable and all its dependant libraries.
104 This does rely on the host file-system being fairly multi-arch
105 aware so the file don't clash with the guests layout."""
107 _copy_with_mkdir(src, dest_dir, "/usr/bin")
109 libs = _get_so_libs(src)
112 so_path = os.path.dirname(l)
113 _copy_with_mkdir(l , dest_dir, so_path)
116 def _check_binfmt_misc(executable):
117 """Check binfmt_misc has entry for executable in the right place.
119 The details of setting up binfmt_misc are outside the scope of
120 this script but we should at least fail early with a useful
121 message if it won't work."""
123 binary = os.path.basename(executable)
124 binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
126 if not os.path.exists(binfmt_entry):
127 print ("No binfmt_misc entry for %s" % (binary))
130 with open(binfmt_entry) as x: entry = x.read()
132 qpath = "/usr/bin/%s" % (binary)
133 if not re.search("interpreter %s\n" % (qpath), entry):
134 print ("binfmt_misc for %s does not point to %s" % (binary, qpath))
140 def _read_qemu_dockerfile(img_name):
141 # special case for Debian linux-user images
142 if img_name.startswith("debian") and img_name.endswith("user"):
143 img_name = "debian-bootstrap"
145 df = os.path.join(os.path.dirname(__file__), "dockerfiles",
146 img_name + ".docker")
147 return open(df, "r").read()
149 def _dockerfile_preprocess(df):
151 for l in df.splitlines():
152 if len(l.strip()) == 0 or l.startswith("#"):
154 from_pref = "FROM qemu:"
155 if l.startswith(from_pref):
156 # TODO: Alternatively we could replace this line with "FROM $ID"
157 # where $ID is the image's hex id obtained with
158 # $ docker images $IMAGE --format="{{.Id}}"
159 # but unfortunately that's not supported by RHEL 7.
160 inlining = _read_qemu_dockerfile(l[len(from_pref):])
161 out += _dockerfile_preprocess(inlining)
166 class Docker(object):
167 """ Running Docker commands """
169 self._command = _guess_docker_command()
171 atexit.register(self._kill_instances)
172 signal.signal(signal.SIGTERM, self._kill_instances)
173 signal.signal(signal.SIGHUP, self._kill_instances)
175 def _do(self, cmd, quiet=True, **kwargs):
177 kwargs["stdout"] = DEVNULL
178 return subprocess.call(self._command + cmd, **kwargs)
180 def _do_check(self, cmd, quiet=True, **kwargs):
182 kwargs["stdout"] = DEVNULL
183 return subprocess.check_call(self._command + cmd, **kwargs)
185 def _do_kill_instances(self, only_known, only_active=True):
189 for i in self._output(cmd).split():
190 resp = self._output(["inspect", i])
191 labels = json.loads(resp)[0]["Config"]["Labels"]
192 active = json.loads(resp)[0]["State"]["Running"]
195 instance_uuid = labels.get("com.qemu.instance.uuid", None)
196 if not instance_uuid:
198 if only_known and instance_uuid not in self._instances:
200 print("Terminating", i)
202 self._do(["kill", i])
206 self._do_kill_instances(False, False)
209 def _kill_instances(self, *args, **kwargs):
210 return self._do_kill_instances(True)
212 def _output(self, cmd, **kwargs):
213 return subprocess.check_output(self._command + cmd,
214 stderr=subprocess.STDOUT,
217 def inspect_tag(self, tag):
219 return self._output(["inspect", tag])
220 except subprocess.CalledProcessError:
223 def get_image_creation_time(self, info):
224 return json.loads(info)[0]["Created"]
226 def get_image_dockerfile_checksum(self, tag):
227 resp = self.inspect_tag(tag)
228 labels = json.loads(resp)[0]["Config"].get("Labels", {})
229 return labels.get("com.qemu.dockerfile-checksum", "")
231 def build_image(self, tag, docker_dir, dockerfile,
232 quiet=True, user=False, argv=None, extra_files_cksum=[]):
236 tmp_df = tempfile.NamedTemporaryFile(dir=docker_dir, suffix=".docker")
237 tmp_df.write(dockerfile)
241 uname = getpwuid(uid).pw_name
243 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
247 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" %
248 _text_checksum(_dockerfile_preprocess(dockerfile)))
249 for f, c in extra_files_cksum:
250 tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c))
254 self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv + \
258 def update_image(self, tag, tarball, quiet=True):
259 "Update a tagged image using "
261 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
263 def image_matches_dockerfile(self, tag, dockerfile):
265 checksum = self.get_image_dockerfile_checksum(tag)
268 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile))
270 def run(self, cmd, keep, quiet):
271 label = uuid.uuid1().hex
273 self._instances.append(label)
274 ret = self._do_check(["run", "--label",
275 "com.qemu.instance.uuid=" + label] + cmd,
278 self._instances.remove(label)
281 def command(self, cmd, argv, quiet):
282 return self._do([cmd] + argv, quiet=quiet)
284 class SubCommand(object):
285 """A SubCommand template base class"""
286 name = None # Subcommand name
287 def shared_args(self, parser):
288 parser.add_argument("--quiet", action="store_true",
289 help="Run quietly unless an error occured")
291 def args(self, parser):
292 """Setup argument parser"""
294 def run(self, args, argv):
296 args: parsed argument by argument parser.
297 argv: remaining arguments from sys.argv.
301 class RunCommand(SubCommand):
302 """Invoke docker run and take care of cleaning up"""
304 def args(self, parser):
305 parser.add_argument("--keep", action="store_true",
306 help="Don't remove image when command completes")
307 def run(self, args, argv):
308 return Docker().run(argv, args.keep, quiet=args.quiet)
310 class BuildCommand(SubCommand):
311 """ Build docker image out of a dockerfile. Arguments: <tag> <dockerfile>"""
313 def args(self, parser):
314 parser.add_argument("--include-executable", "-e",
315 help="""Specify a binary that will be copied to the
316 container together with all its dependent
318 parser.add_argument("--extra-files", "-f", nargs='*',
319 help="""Specify files that will be copied in the
320 Docker image, fulfilling the ADD directive from the
322 parser.add_argument("--add-current-user", "-u", dest="user",
324 help="Add the current user to image's passwd")
325 parser.add_argument("tag",
327 parser.add_argument("dockerfile",
328 help="Dockerfile name")
330 def run(self, args, argv):
331 dockerfile = open(args.dockerfile, "rb").read()
335 if "--no-cache" not in argv and \
336 dkr.image_matches_dockerfile(tag, dockerfile):
338 print("Image is up to date.")
340 # Create a docker context directory for the build
341 docker_dir = tempfile.mkdtemp(prefix="docker_build")
343 # Validate binfmt_misc will work
344 if args.include_executable:
345 if not _check_binfmt_misc(args.include_executable):
348 # Is there a .pre file to run in the build context?
349 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
350 if os.path.exists(docker_pre):
351 stdout = DEVNULL if args.quiet else None
352 rc = subprocess.call(os.path.realpath(docker_pre),
353 cwd=docker_dir, stdout=stdout)
358 print("%s exited with code %d" % (docker_pre, rc))
361 # Copy any extra files into the Docker context. These can be
362 # included by the use of the ADD directive in the Dockerfile.
364 if args.include_executable:
365 # FIXME: there is no checksum of this executable and the linked
366 # libraries, once the image built any change of this executable
367 # or any library won't trigger another build.
368 _copy_binary_with_libs(args.include_executable, docker_dir)
369 for filename in args.extra_files or []:
370 _copy_with_mkdir(filename, docker_dir)
371 cksum += [(filename, _file_checksum(filename))]
373 argv += ["--build-arg=" + k.lower() + "=" + v
374 for k, v in os.environ.iteritems()
375 if k.lower() in FILTERED_ENV_NAMES]
376 dkr.build_image(tag, docker_dir, dockerfile,
377 quiet=args.quiet, user=args.user, argv=argv,
378 extra_files_cksum=cksum)
384 class UpdateCommand(SubCommand):
385 """ Update a docker image with new executables. Arguments: <tag> <executable>"""
387 def args(self, parser):
388 parser.add_argument("tag",
390 parser.add_argument("executable",
391 help="Executable to copy")
393 def run(self, args, argv):
394 # Create a temporary tarball with our whole build context and
395 # dockerfile for the update
396 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
397 tmp_tar = TarFile(fileobj=tmp, mode='w')
399 # Add the executable to the tarball
400 bn = os.path.basename(args.executable)
401 ff = "/usr/bin/%s" % bn
402 tmp_tar.add(args.executable, arcname=ff)
404 # Add any associated libraries
405 libs = _get_so_libs(args.executable)
408 tmp_tar.add(os.path.realpath(l), arcname=l)
410 # Create a Docker buildfile
412 df.write("FROM %s\n" % args.tag)
413 df.write("ADD . /\n")
416 df_tar = TarInfo(name="Dockerfile")
417 df_tar.size = len(df.buf)
418 tmp_tar.addfile(df_tar, fileobj=df)
422 # reset the file pointers
426 # Run the build with our tarball context
428 dkr.update_image(args.tag, tmp, quiet=args.quiet)
432 class CleanCommand(SubCommand):
433 """Clean up docker instances"""
435 def run(self, args, argv):
439 class ImagesCommand(SubCommand):
440 """Run "docker images" command"""
442 def run(self, args, argv):
443 return Docker().command("images", argv, args.quiet)
446 class ProbeCommand(SubCommand):
447 """Probe if we can run docker automatically"""
450 def run(self, args, argv):
453 if docker._command[0] == "docker":
455 elif docker._command[0] == "sudo":
463 class CcCommand(SubCommand):
464 """Compile sources with cc in images"""
467 def args(self, parser):
468 parser.add_argument("--image", "-i", required=True,
469 help="The docker image in which to run cc")
470 parser.add_argument("--cc", default="cc",
471 help="The compiler executable to call")
472 parser.add_argument("--user",
473 help="The user-id to run under")
474 parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
475 help="""Extra paths to (ro) mount into container for
478 def run(self, args, argv):
479 if argv and argv[0] == "--":
482 cmd = ["--rm", "-w", cwd,
483 "-v", "%s:%s:rw" % (cwd, cwd)]
486 cmd += ["-v", "%s:%s:ro,z" % (p, p)]
488 cmd += ["-u", args.user]
489 cmd += [args.image, args.cc]
491 return Docker().command("run", cmd, args.quiet)
494 class CheckCommand(SubCommand):
495 """Check if we need to re-build a docker image out of a dockerfile.
496 Arguments: <tag> <dockerfile>"""
499 def args(self, parser):
500 parser.add_argument("tag",
502 parser.add_argument("dockerfile", default=None,
503 help="Dockerfile name", nargs='?')
504 parser.add_argument("--checktype", choices=["checksum", "age"],
505 default="checksum", help="check type")
506 parser.add_argument("--olderthan", default=60, type=int,
507 help="number of minutes")
509 def run(self, args, argv):
515 print("Docker not set up")
518 info = dkr.inspect_tag(tag)
520 print("Image does not exist")
523 if args.checktype == "checksum":
524 if not args.dockerfile:
525 print("Need a dockerfile for tag:%s" % (tag))
528 dockerfile = open(args.dockerfile, "rb").read()
530 if dkr.image_matches_dockerfile(tag, dockerfile):
532 print("Image is up to date")
535 print("Image needs updating")
537 elif args.checktype == "age":
538 timestr = dkr.get_image_creation_time(info).split(".")[0]
539 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
540 past = datetime.now() - timedelta(minutes=args.olderthan)
542 print ("Image created @ %s more than %d minutes old" %
543 (timestr, args.olderthan))
547 print ("Image less than %d minutes old" % (args.olderthan))
552 parser = argparse.ArgumentParser(description="A Docker helper",
553 usage="%s <subcommand> ..." % os.path.basename(sys.argv[0]))
554 subparsers = parser.add_subparsers(title="subcommands", help=None)
555 for cls in SubCommand.__subclasses__():
557 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
558 cmd.shared_args(subp)
560 subp.set_defaults(cmdobj=cmd)
561 args, argv = parser.parse_known_args()
562 return args.cmdobj.run(args, argv)
564 if __name__ == "__main__":