3 # Docker controlling module
5 # Copyright (c) 2016 Red Hat Inc.
8 # Fam Zheng <famz@redhat.com>
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
26 from tarfile
import TarFile
, TarInfo
28 from StringIO
import StringIO
30 from io
import StringIO
31 from shutil
import copy
, rmtree
32 from pwd
import getpwuid
33 from datetime
import datetime
, timedelta
36 FILTERED_ENV_NAMES
= ['ftp_proxy', 'http_proxy', 'https_proxy']
39 DEVNULL
= open(os
.devnull
, 'wb')
42 def _text_checksum(text
):
43 """Calculate a digest string unique to the text content"""
44 return hashlib
.sha1(text
).hexdigest()
47 def _file_checksum(filename
):
48 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" %
68 def _copy_with_mkdir(src
, root_dir
, sub_path
='.'):
69 """Copy src into root_dir, creating sub_path as needed."""
70 dest_dir
= os
.path
.normpath("%s/%s" % (root_dir
, sub_path
))
74 # we can safely ignore already created directories
77 dest_file
= "%s/%s" % (dest_dir
, os
.path
.basename(src
))
81 def _get_so_libs(executable
):
82 """Return a list of libraries associated with an executable.
84 The paths may be symbolic links which would need to be resolved to
85 ensure theright data is copied."""
88 ldd_re
= re
.compile(r
"(/.*/)(\S*)")
90 ldd_output
= subprocess
.check_output(["ldd", executable
])
91 for line
in ldd_output
.split("\n"):
92 search
= ldd_re
.search(line
)
93 if search
and len(search
.groups()) == 2:
94 so_path
= search
.groups()[0]
95 so_lib
= search
.groups()[1]
96 libs
.append("%s/%s" % (so_path
, so_lib
))
97 except subprocess
.CalledProcessError
:
98 print("%s had no associated libraries (static build?)" % (executable
))
103 def _copy_binary_with_libs(src
, bin_dest
, dest_dir
):
104 """Maybe copy a binary and all its dependent libraries.
106 If bin_dest isn't set we only copy the support libraries because
107 we don't need qemu in the docker path to run (due to persistent
108 mapping). Indeed users may get confused if we aren't running what
111 This does rely on the host file-system being fairly multi-arch
112 aware so the file don't clash with the guests layout.
116 _copy_with_mkdir(src
, dest_dir
, os
.path
.dirname(bin_dest
))
118 print("only copying support libraries for %s" % (src
))
120 libs
= _get_so_libs(src
)
123 so_path
= os
.path
.dirname(l
)
124 _copy_with_mkdir(l
, dest_dir
, so_path
)
127 def _check_binfmt_misc(executable
):
128 """Check binfmt_misc has entry for executable in the right place.
130 The details of setting up binfmt_misc are outside the scope of
131 this script but we should at least fail early with a useful
132 message if it won't work.
134 Returns the configured binfmt path and a valid flag. For
135 persistent configurations we will still want to copy and dependent
139 binary
= os
.path
.basename(executable
)
140 binfmt_entry
= "/proc/sys/fs/binfmt_misc/%s" % (binary
)
142 if not os
.path
.exists(binfmt_entry
):
143 print ("No binfmt_misc entry for %s" % (binary
))
146 with
open(binfmt_entry
) as x
: entry
= x
.read()
148 if re
.search("flags:.*F.*\n", entry
):
149 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
153 m
= re
.search("interpreter (\S+)\n", entry
)
155 if interp
and interp
!= executable
:
156 print("binfmt_misc for %s does not point to %s, using %s" %
157 (binary
, executable
, interp
))
162 def _read_qemu_dockerfile(img_name
):
163 # special case for Debian linux-user images
164 if img_name
.startswith("debian") and img_name
.endswith("user"):
165 img_name
= "debian-bootstrap"
167 df
= os
.path
.join(os
.path
.dirname(__file__
), "dockerfiles",
168 img_name
+ ".docker")
169 return open(df
, "r").read()
172 def _dockerfile_preprocess(df
):
174 for l
in df
.splitlines():
175 if len(l
.strip()) == 0 or l
.startswith("#"):
177 from_pref
= "FROM qemu:"
178 if l
.startswith(from_pref
):
179 # TODO: Alternatively we could replace this line with "FROM $ID"
180 # where $ID is the image's hex id obtained with
181 # $ docker images $IMAGE --format="{{.Id}}"
182 # but unfortunately that's not supported by RHEL 7.
183 inlining
= _read_qemu_dockerfile(l
[len(from_pref
):])
184 out
+= _dockerfile_preprocess(inlining
)
190 class Docker(object):
191 """ Running Docker commands """
193 self
._command
= _guess_docker_command()
195 atexit
.register(self
._kill
_instances
)
196 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
197 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
199 def _do(self
, cmd
, quiet
=True, **kwargs
):
201 kwargs
["stdout"] = DEVNULL
202 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
204 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
206 kwargs
["stdout"] = DEVNULL
207 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
209 def _do_kill_instances(self
, only_known
, only_active
=True):
213 for i
in self
._output
(cmd
).split():
214 resp
= self
._output
(["inspect", i
])
215 labels
= json
.loads(resp
)[0]["Config"]["Labels"]
216 active
= json
.loads(resp
)[0]["State"]["Running"]
219 instance_uuid
= labels
.get("com.qemu.instance.uuid", None)
220 if not instance_uuid
:
222 if only_known
and instance_uuid
not in self
._instances
:
224 print("Terminating", i
)
226 self
._do
(["kill", i
])
230 self
._do
_kill
_instances
(False, False)
233 def _kill_instances(self
, *args
, **kwargs
):
234 return self
._do
_kill
_instances
(True)
236 def _output(self
, cmd
, **kwargs
):
237 return subprocess
.check_output(self
._command
+ cmd
,
238 stderr
=subprocess
.STDOUT
,
241 def inspect_tag(self
, tag
):
243 return self
._output
(["inspect", tag
])
244 except subprocess
.CalledProcessError
:
247 def get_image_creation_time(self
, info
):
248 return json
.loads(info
)[0]["Created"]
250 def get_image_dockerfile_checksum(self
, tag
):
251 resp
= self
.inspect_tag(tag
)
252 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
253 return labels
.get("com.qemu.dockerfile-checksum", "")
255 def build_image(self
, tag
, docker_dir
, dockerfile
,
256 quiet
=True, user
=False, argv
=None, extra_files_cksum
=[]):
260 tmp_df
= tempfile
.NamedTemporaryFile(dir=docker_dir
, suffix
=".docker")
261 tmp_df
.write(dockerfile
)
265 uname
= getpwuid(uid
).pw_name
267 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
271 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s" %
272 _text_checksum(_dockerfile_preprocess(dockerfile
)))
273 for f
, c
in extra_files_cksum
:
274 tmp_df
.write("LABEL com.qemu.%s-checksum=%s" % (f
, c
))
278 self
._do
_check
(["build", "-t", tag
, "-f", tmp_df
.name
] + argv
+
282 def update_image(self
, tag
, tarball
, quiet
=True):
283 "Update a tagged image using "
285 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
287 def image_matches_dockerfile(self
, tag
, dockerfile
):
289 checksum
= self
.get_image_dockerfile_checksum(tag
)
292 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
294 def run(self
, cmd
, keep
, quiet
):
295 label
= uuid
.uuid1().hex
297 self
._instances
.append(label
)
298 ret
= self
._do
_check
(["run", "--label",
299 "com.qemu.instance.uuid=" + label
] + cmd
,
302 self
._instances
.remove(label
)
305 def command(self
, cmd
, argv
, quiet
):
306 return self
._do
([cmd
] + argv
, quiet
=quiet
)
309 class SubCommand(object):
310 """A SubCommand template base class"""
311 name
= None # Subcommand name
313 def shared_args(self
, parser
):
314 parser
.add_argument("--quiet", action
="store_true",
315 help="Run quietly unless an error occurred")
317 def args(self
, parser
):
318 """Setup argument parser"""
321 def run(self
, args
, argv
):
323 args: parsed argument by argument parser.
324 argv: remaining arguments from sys.argv.
329 class RunCommand(SubCommand
):
330 """Invoke docker run and take care of cleaning up"""
333 def args(self
, parser
):
334 parser
.add_argument("--keep", action
="store_true",
335 help="Don't remove image when command completes")
337 def run(self
, args
, argv
):
338 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
)
341 class BuildCommand(SubCommand
):
342 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
345 def args(self
, parser
):
346 parser
.add_argument("--include-executable", "-e",
347 help="""Specify a binary that will be copied to the
348 container together with all its dependent
350 parser
.add_argument("--extra-files", "-f", nargs
='*',
351 help="""Specify files that will be copied in the
352 Docker image, fulfilling the ADD directive from the
354 parser
.add_argument("--add-current-user", "-u", dest
="user",
356 help="Add the current user to image's passwd")
357 parser
.add_argument("tag",
359 parser
.add_argument("dockerfile",
360 help="Dockerfile name")
362 def run(self
, args
, argv
):
363 dockerfile
= open(args
.dockerfile
, "rb").read()
367 if "--no-cache" not in argv
and \
368 dkr
.image_matches_dockerfile(tag
, dockerfile
):
370 print("Image is up to date.")
372 # Create a docker context directory for the build
373 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
375 # Validate binfmt_misc will work
376 if args
.include_executable
:
377 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
381 # Is there a .pre file to run in the build context?
382 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
383 if os
.path
.exists(docker_pre
):
384 stdout
= DEVNULL
if args
.quiet
else None
385 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
386 cwd
=docker_dir
, stdout
=stdout
)
391 print("%s exited with code %d" % (docker_pre
, rc
))
394 # Copy any extra files into the Docker context. These can be
395 # included by the use of the ADD directive in the Dockerfile.
397 if args
.include_executable
:
398 # FIXME: there is no checksum of this executable and the linked
399 # libraries, once the image built any change of this executable
400 # or any library won't trigger another build.
401 _copy_binary_with_libs(args
.include_executable
,
404 for filename
in args
.extra_files
or []:
405 _copy_with_mkdir(filename
, docker_dir
)
406 cksum
+= [(filename
, _file_checksum(filename
))]
408 argv
+= ["--build-arg=" + k
.lower() + "=" + v
409 for k
, v
in os
.environ
.iteritems()
410 if k
.lower() in FILTERED_ENV_NAMES
]
411 dkr
.build_image(tag
, docker_dir
, dockerfile
,
412 quiet
=args
.quiet
, user
=args
.user
, argv
=argv
,
413 extra_files_cksum
=cksum
)
420 class UpdateCommand(SubCommand
):
421 """ Update a docker image with new executables. Args: <tag> <executable>"""
424 def args(self
, parser
):
425 parser
.add_argument("tag",
427 parser
.add_argument("executable",
428 help="Executable to copy")
430 def run(self
, args
, argv
):
431 # Create a temporary tarball with our whole build context and
432 # dockerfile for the update
433 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
434 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
436 # Add the executable to the tarball, using the current
437 # configured binfmt_misc path. If we don't get a path then we
438 # only need the support libraries copied
439 ff
, enabled
= _check_binfmt_misc(args
.executable
)
442 print("binfmt_misc not enabled, update disabled")
446 tmp_tar
.add(args
.executable
, arcname
=ff
)
448 # Add any associated libraries
449 libs
= _get_so_libs(args
.executable
)
452 tmp_tar
.add(os
.path
.realpath(l
), arcname
=l
)
454 # Create a Docker buildfile
456 df
.write("FROM %s\n" % args
.tag
)
457 df
.write("ADD . /\n")
460 df_tar
= TarInfo(name
="Dockerfile")
461 df_tar
.size
= len(df
.buf
)
462 tmp_tar
.addfile(df_tar
, fileobj
=df
)
466 # reset the file pointers
470 # Run the build with our tarball context
472 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
477 class CleanCommand(SubCommand
):
478 """Clean up docker instances"""
481 def run(self
, args
, argv
):
486 class ImagesCommand(SubCommand
):
487 """Run "docker images" command"""
490 def run(self
, args
, argv
):
491 return Docker().command("images", argv
, args
.quiet
)
494 class ProbeCommand(SubCommand
):
495 """Probe if we can run docker automatically"""
498 def run(self
, args
, argv
):
501 if docker
._command
[0] == "docker":
503 elif docker
._command
[0] == "sudo":
511 class CcCommand(SubCommand
):
512 """Compile sources with cc in images"""
515 def args(self
, parser
):
516 parser
.add_argument("--image", "-i", required
=True,
517 help="The docker image in which to run cc")
518 parser
.add_argument("--cc", default
="cc",
519 help="The compiler executable to call")
520 parser
.add_argument("--user",
521 help="The user-id to run under")
522 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
523 help="""Extra paths to (ro) mount into container for
526 def run(self
, args
, argv
):
527 if argv
and argv
[0] == "--":
530 cmd
= ["--rm", "-w", cwd
,
531 "-v", "%s:%s:rw" % (cwd
, cwd
)]
534 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
536 cmd
+= ["-u", args
.user
]
537 cmd
+= [args
.image
, args
.cc
]
539 return Docker().command("run", cmd
, args
.quiet
)
542 class CheckCommand(SubCommand
):
543 """Check if we need to re-build a docker image out of a dockerfile.
544 Arguments: <tag> <dockerfile>"""
547 def args(self
, parser
):
548 parser
.add_argument("tag",
550 parser
.add_argument("dockerfile", default
=None,
551 help="Dockerfile name", nargs
='?')
552 parser
.add_argument("--checktype", choices
=["checksum", "age"],
553 default
="checksum", help="check type")
554 parser
.add_argument("--olderthan", default
=60, type=int,
555 help="number of minutes")
557 def run(self
, args
, argv
):
562 except subprocess
.CalledProcessError
:
563 print("Docker not set up")
566 info
= dkr
.inspect_tag(tag
)
568 print("Image does not exist")
571 if args
.checktype
== "checksum":
572 if not args
.dockerfile
:
573 print("Need a dockerfile for tag:%s" % (tag
))
576 dockerfile
= open(args
.dockerfile
, "rb").read()
578 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
580 print("Image is up to date")
583 print("Image needs updating")
585 elif args
.checktype
== "age":
586 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
587 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
588 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
590 print ("Image created @ %s more than %d minutes old" %
591 (timestr
, args
.olderthan
))
595 print ("Image less than %d minutes old" % (args
.olderthan
))
600 parser
= argparse
.ArgumentParser(description
="A Docker helper",
601 usage
="%s <subcommand> ..." %
602 os
.path
.basename(sys
.argv
[0]))
603 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
604 for cls
in SubCommand
.__subclasses
__():
606 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
607 cmd
.shared_args(subp
)
609 subp
.set_defaults(cmdobj
=cmd
)
610 args
, argv
= parser
.parse_known_args()
611 return args
.cmdobj
.run(args
, argv
)
614 if __name__
== "__main__":