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()
46 def _file_checksum(filename
):
47 return _text_checksum(open(filename
, 'rb').read())
49 def _guess_docker_command():
50 """ Guess a working docker command or raise exception if not found"""
51 commands
= [["docker"], ["sudo", "-n", "docker"]]
54 # docker version will return the client details in stdout
55 # but still report a status of 1 if it can't contact the daemon
56 if subprocess
.call(cmd
+ ["version"],
57 stdout
=DEVNULL
, stderr
=DEVNULL
) == 0:
61 commands_txt
= "\n".join([" " + " ".join(x
) for x
in commands
])
62 raise Exception("Cannot find working docker command. Tried:\n%s" % \
65 def _copy_with_mkdir(src
, root_dir
, sub_path
='.'):
66 """Copy src into root_dir, creating sub_path as needed."""
67 dest_dir
= os
.path
.normpath("%s/%s" % (root_dir
, sub_path
))
71 # we can safely ignore already created directories
74 dest_file
= "%s/%s" % (dest_dir
, os
.path
.basename(src
))
78 def _get_so_libs(executable
):
79 """Return a list of libraries associated with an executable.
81 The paths may be symbolic links which would need to be resolved to
82 ensure theright data is copied."""
85 ldd_re
= re
.compile(r
"(/.*/)(\S*)")
87 ldd_output
= subprocess
.check_output(["ldd", executable
])
88 for line
in ldd_output
.split("\n"):
89 search
= ldd_re
.search(line
)
90 if search
and len(search
.groups()) == 2:
91 so_path
= search
.groups()[0]
92 so_lib
= search
.groups()[1]
93 libs
.append("%s/%s" % (so_path
, so_lib
))
94 except subprocess
.CalledProcessError
:
95 print("%s had no associated libraries (static build?)" % (executable
))
99 def _copy_binary_with_libs(src
, dest_dir
):
100 """Copy a binary executable and all its dependent libraries.
102 This does rely on the host file-system being fairly multi-arch
103 aware so the file don't clash with the guests layout."""
105 _copy_with_mkdir(src
, dest_dir
, "/usr/bin")
107 libs
= _get_so_libs(src
)
110 so_path
= os
.path
.dirname(l
)
111 _copy_with_mkdir(l
, dest_dir
, so_path
)
114 def _check_binfmt_misc(executable
):
115 """Check binfmt_misc has entry for executable in the right place.
117 The details of setting up binfmt_misc are outside the scope of
118 this script but we should at least fail early with a useful
119 message if it won't work."""
121 binary
= os
.path
.basename(executable
)
122 binfmt_entry
= "/proc/sys/fs/binfmt_misc/%s" % (binary
)
124 if not os
.path
.exists(binfmt_entry
):
125 print ("No binfmt_misc entry for %s" % (binary
))
128 with
open(binfmt_entry
) as x
: entry
= x
.read()
130 qpath
= "/usr/bin/%s" % (binary
)
131 if not re
.search("interpreter %s\n" % (qpath
), entry
):
132 print ("binfmt_misc for %s does not point to %s" % (binary
, qpath
))
138 def _read_qemu_dockerfile(img_name
):
139 # special case for Debian linux-user images
140 if img_name
.startswith("debian") and img_name
.endswith("user"):
141 img_name
= "debian-bootstrap"
143 df
= os
.path
.join(os
.path
.dirname(__file__
), "dockerfiles",
144 img_name
+ ".docker")
145 return open(df
, "r").read()
147 def _dockerfile_preprocess(df
):
149 for l
in df
.splitlines():
150 if len(l
.strip()) == 0 or l
.startswith("#"):
152 from_pref
= "FROM qemu:"
153 if l
.startswith(from_pref
):
154 # TODO: Alternatively we could replace this line with "FROM $ID"
155 # where $ID is the image's hex id obtained with
156 # $ docker images $IMAGE --format="{{.Id}}"
157 # but unfortunately that's not supported by RHEL 7.
158 inlining
= _read_qemu_dockerfile(l
[len(from_pref
):])
159 out
+= _dockerfile_preprocess(inlining
)
164 class Docker(object):
165 """ Running Docker commands """
167 self
._command
= _guess_docker_command()
169 atexit
.register(self
._kill
_instances
)
170 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
171 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
173 def _do(self
, cmd
, quiet
=True, **kwargs
):
175 kwargs
["stdout"] = DEVNULL
176 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
178 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
180 kwargs
["stdout"] = DEVNULL
181 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
183 def _do_kill_instances(self
, only_known
, only_active
=True):
187 for i
in self
._output
(cmd
).split():
188 resp
= self
._output
(["inspect", i
])
189 labels
= json
.loads(resp
)[0]["Config"]["Labels"]
190 active
= json
.loads(resp
)[0]["State"]["Running"]
193 instance_uuid
= labels
.get("com.qemu.instance.uuid", None)
194 if not instance_uuid
:
196 if only_known
and instance_uuid
not in self
._instances
:
198 print("Terminating", i
)
200 self
._do
(["kill", i
])
204 self
._do
_kill
_instances
(False, False)
207 def _kill_instances(self
, *args
, **kwargs
):
208 return self
._do
_kill
_instances
(True)
210 def _output(self
, cmd
, **kwargs
):
211 return subprocess
.check_output(self
._command
+ cmd
,
212 stderr
=subprocess
.STDOUT
,
215 def inspect_tag(self
, tag
):
217 return self
._output
(["inspect", tag
])
218 except subprocess
.CalledProcessError
:
221 def get_image_creation_time(self
, info
):
222 return json
.loads(info
)[0]["Created"]
224 def get_image_dockerfile_checksum(self
, tag
):
225 resp
= self
.inspect_tag(tag
)
226 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
227 return labels
.get("com.qemu.dockerfile-checksum", "")
229 def build_image(self
, tag
, docker_dir
, dockerfile
,
230 quiet
=True, user
=False, argv
=None, extra_files_cksum
=[]):
234 tmp_df
= tempfile
.NamedTemporaryFile(dir=docker_dir
, suffix
=".docker")
235 tmp_df
.write(dockerfile
)
239 uname
= getpwuid(uid
).pw_name
241 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
245 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s" %
246 _text_checksum(_dockerfile_preprocess(dockerfile
)))
247 for f
, c
in extra_files_cksum
:
248 tmp_df
.write("LABEL com.qemu.%s-checksum=%s" % (f
, c
))
252 self
._do
_check
(["build", "-t", tag
, "-f", tmp_df
.name
] + argv
+ \
256 def update_image(self
, tag
, tarball
, quiet
=True):
257 "Update a tagged image using "
259 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
261 def image_matches_dockerfile(self
, tag
, dockerfile
):
263 checksum
= self
.get_image_dockerfile_checksum(tag
)
266 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
268 def run(self
, cmd
, keep
, quiet
):
269 label
= uuid
.uuid1().hex
271 self
._instances
.append(label
)
272 ret
= self
._do
_check
(["run", "--label",
273 "com.qemu.instance.uuid=" + label
] + cmd
,
276 self
._instances
.remove(label
)
279 def command(self
, cmd
, argv
, quiet
):
280 return self
._do
([cmd
] + argv
, quiet
=quiet
)
282 class SubCommand(object):
283 """A SubCommand template base class"""
284 name
= None # Subcommand name
285 def shared_args(self
, parser
):
286 parser
.add_argument("--quiet", action
="store_true",
287 help="Run quietly unless an error occurred")
289 def args(self
, parser
):
290 """Setup argument parser"""
292 def run(self
, args
, argv
):
294 args: parsed argument by argument parser.
295 argv: remaining arguments from sys.argv.
299 class RunCommand(SubCommand
):
300 """Invoke docker run and take care of cleaning up"""
302 def args(self
, parser
):
303 parser
.add_argument("--keep", action
="store_true",
304 help="Don't remove image when command completes")
305 def run(self
, args
, argv
):
306 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
)
308 class BuildCommand(SubCommand
):
309 """ Build docker image out of a dockerfile. Arguments: <tag> <dockerfile>"""
311 def args(self
, parser
):
312 parser
.add_argument("--include-executable", "-e",
313 help="""Specify a binary that will be copied to the
314 container together with all its dependent
316 parser
.add_argument("--extra-files", "-f", nargs
='*',
317 help="""Specify files that will be copied in the
318 Docker image, fulfilling the ADD directive from the
320 parser
.add_argument("--add-current-user", "-u", dest
="user",
322 help="Add the current user to image's passwd")
323 parser
.add_argument("tag",
325 parser
.add_argument("dockerfile",
326 help="Dockerfile name")
328 def run(self
, args
, argv
):
329 dockerfile
= open(args
.dockerfile
, "rb").read()
333 if "--no-cache" not in argv
and \
334 dkr
.image_matches_dockerfile(tag
, dockerfile
):
336 print("Image is up to date.")
338 # Create a docker context directory for the build
339 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
341 # Validate binfmt_misc will work
342 if args
.include_executable
:
343 if not _check_binfmt_misc(args
.include_executable
):
346 # Is there a .pre file to run in the build context?
347 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
348 if os
.path
.exists(docker_pre
):
349 stdout
= DEVNULL
if args
.quiet
else None
350 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
351 cwd
=docker_dir
, stdout
=stdout
)
356 print("%s exited with code %d" % (docker_pre
, rc
))
359 # Copy any extra files into the Docker context. These can be
360 # included by the use of the ADD directive in the Dockerfile.
362 if args
.include_executable
:
363 # FIXME: there is no checksum of this executable and the linked
364 # libraries, once the image built any change of this executable
365 # or any library won't trigger another build.
366 _copy_binary_with_libs(args
.include_executable
, docker_dir
)
367 for filename
in args
.extra_files
or []:
368 _copy_with_mkdir(filename
, docker_dir
)
369 cksum
+= [(filename
, _file_checksum(filename
))]
371 argv
+= ["--build-arg=" + k
.lower() + "=" + v
372 for k
, v
in os
.environ
.iteritems()
373 if k
.lower() in FILTERED_ENV_NAMES
]
374 dkr
.build_image(tag
, docker_dir
, dockerfile
,
375 quiet
=args
.quiet
, user
=args
.user
, argv
=argv
,
376 extra_files_cksum
=cksum
)
382 class UpdateCommand(SubCommand
):
383 """ Update a docker image with new executables. Arguments: <tag> <executable>"""
385 def args(self
, parser
):
386 parser
.add_argument("tag",
388 parser
.add_argument("executable",
389 help="Executable to copy")
391 def run(self
, args
, argv
):
392 # Create a temporary tarball with our whole build context and
393 # dockerfile for the update
394 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
395 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
397 # Add the executable to the tarball
398 bn
= os
.path
.basename(args
.executable
)
399 ff
= "/usr/bin/%s" % bn
400 tmp_tar
.add(args
.executable
, arcname
=ff
)
402 # Add any associated libraries
403 libs
= _get_so_libs(args
.executable
)
406 tmp_tar
.add(os
.path
.realpath(l
), arcname
=l
)
408 # Create a Docker buildfile
410 df
.write("FROM %s\n" % args
.tag
)
411 df
.write("ADD . /\n")
414 df_tar
= TarInfo(name
="Dockerfile")
415 df_tar
.size
= len(df
.buf
)
416 tmp_tar
.addfile(df_tar
, fileobj
=df
)
420 # reset the file pointers
424 # Run the build with our tarball context
426 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
430 class CleanCommand(SubCommand
):
431 """Clean up docker instances"""
433 def run(self
, args
, argv
):
437 class ImagesCommand(SubCommand
):
438 """Run "docker images" command"""
440 def run(self
, args
, argv
):
441 return Docker().command("images", argv
, args
.quiet
)
444 class ProbeCommand(SubCommand
):
445 """Probe if we can run docker automatically"""
448 def run(self
, args
, argv
):
451 if docker
._command
[0] == "docker":
453 elif docker
._command
[0] == "sudo":
461 class CcCommand(SubCommand
):
462 """Compile sources with cc in images"""
465 def args(self
, parser
):
466 parser
.add_argument("--image", "-i", required
=True,
467 help="The docker image in which to run cc")
468 parser
.add_argument("--cc", default
="cc",
469 help="The compiler executable to call")
470 parser
.add_argument("--user",
471 help="The user-id to run under")
472 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
473 help="""Extra paths to (ro) mount into container for
476 def run(self
, args
, argv
):
477 if argv
and argv
[0] == "--":
480 cmd
= ["--rm", "-w", cwd
,
481 "-v", "%s:%s:rw" % (cwd
, cwd
)]
484 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
486 cmd
+= ["-u", args
.user
]
487 cmd
+= [args
.image
, args
.cc
]
489 return Docker().command("run", cmd
, args
.quiet
)
492 class CheckCommand(SubCommand
):
493 """Check if we need to re-build a docker image out of a dockerfile.
494 Arguments: <tag> <dockerfile>"""
497 def args(self
, parser
):
498 parser
.add_argument("tag",
500 parser
.add_argument("dockerfile", default
=None,
501 help="Dockerfile name", nargs
='?')
502 parser
.add_argument("--checktype", choices
=["checksum", "age"],
503 default
="checksum", help="check type")
504 parser
.add_argument("--olderthan", default
=60, type=int,
505 help="number of minutes")
507 def run(self
, args
, argv
):
513 print("Docker not set up")
516 info
= dkr
.inspect_tag(tag
)
518 print("Image does not exist")
521 if args
.checktype
== "checksum":
522 if not args
.dockerfile
:
523 print("Need a dockerfile for tag:%s" % (tag
))
526 dockerfile
= open(args
.dockerfile
, "rb").read()
528 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
530 print("Image is up to date")
533 print("Image needs updating")
535 elif args
.checktype
== "age":
536 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
537 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
538 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
540 print ("Image created @ %s more than %d minutes old" %
541 (timestr
, args
.olderthan
))
545 print ("Image less than %d minutes old" % (args
.olderthan
))
550 parser
= argparse
.ArgumentParser(description
="A Docker helper",
551 usage
="%s <subcommand> ..." % os
.path
.basename(sys
.argv
[0]))
552 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
553 for cls
in SubCommand
.__subclasses
__():
555 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
556 cmd
.shared_args(subp
)
558 subp
.set_defaults(cmdobj
=cmd
)
559 args
, argv
= parser
.parse_known_args()
560 return args
.cmdobj
.run(args
, argv
)
562 if __name__
== "__main__":