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.
26 from tarfile
import TarFile
, TarInfo
27 from io
import StringIO
28 from shutil
import copy
, rmtree
29 from pwd
import getpwuid
30 from datetime
import datetime
, timedelta
33 FILTERED_ENV_NAMES
= ['ftp_proxy', 'http_proxy', 'https_proxy']
36 DEVNULL
= open(os
.devnull
, 'wb')
38 class EngineEnum(enum
.IntEnum
):
44 return self
.name
.lower()
52 return EngineEnum
[s
.upper()]
57 USE_ENGINE
= EngineEnum
.AUTO
59 def _bytes_checksum(bytes
):
60 """Calculate a digest string unique to the text content"""
61 return hashlib
.sha1(bytes
).hexdigest()
63 def _text_checksum(text
):
64 """Calculate a digest string unique to the text content"""
65 return _bytes_checksum(text
.encode('utf-8'))
67 def _read_dockerfile(path
):
68 return open(path
, 'rt', encoding
='utf-8').read()
70 def _file_checksum(filename
):
71 return _bytes_checksum(open(filename
, 'rb').read())
74 def _guess_engine_command():
75 """ Guess a working engine command or raise exception if not found"""
78 if USE_ENGINE
in [EngineEnum
.AUTO
, EngineEnum
.PODMAN
]:
79 commands
+= [["podman"]]
80 if USE_ENGINE
in [EngineEnum
.AUTO
, EngineEnum
.DOCKER
]:
81 commands
+= [["docker"], ["sudo", "-n", "docker"]]
84 # docker version will return the client details in stdout
85 # but still report a status of 1 if it can't contact the daemon
86 if subprocess
.call(cmd
+ ["version"],
87 stdout
=DEVNULL
, stderr
=DEVNULL
) == 0:
91 commands_txt
= "\n".join([" " + " ".join(x
) for x
in commands
])
92 raise Exception("Cannot find working engine command. Tried:\n%s" %
96 def _copy_with_mkdir(src
, root_dir
, sub_path
='.'):
97 """Copy src into root_dir, creating sub_path as needed."""
98 dest_dir
= os
.path
.normpath("%s/%s" % (root_dir
, sub_path
))
100 os
.makedirs(dest_dir
)
102 # we can safely ignore already created directories
105 dest_file
= "%s/%s" % (dest_dir
, os
.path
.basename(src
))
109 def _get_so_libs(executable
):
110 """Return a list of libraries associated with an executable.
112 The paths may be symbolic links which would need to be resolved to
113 ensure the right data is copied."""
116 ldd_re
= re
.compile(r
"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)")
118 ldd_output
= subprocess
.check_output(["ldd", executable
]).decode('utf-8')
119 for line
in ldd_output
.split("\n"):
120 search
= ldd_re
.search(line
)
123 libs
.append(s
.group(1))
126 except subprocess
.CalledProcessError
:
127 print("%s had no associated libraries (static build?)" % (executable
))
132 def _copy_binary_with_libs(src
, bin_dest
, dest_dir
):
133 """Maybe copy a binary and all its dependent libraries.
135 If bin_dest isn't set we only copy the support libraries because
136 we don't need qemu in the docker path to run (due to persistent
137 mapping). Indeed users may get confused if we aren't running what
140 This does rely on the host file-system being fairly multi-arch
141 aware so the file don't clash with the guests layout.
145 _copy_with_mkdir(src
, dest_dir
, os
.path
.dirname(bin_dest
))
147 print("only copying support libraries for %s" % (src
))
149 libs
= _get_so_libs(src
)
152 so_path
= os
.path
.dirname(l
)
153 real_l
= os
.path
.realpath(l
)
154 _copy_with_mkdir(real_l
, dest_dir
, so_path
)
157 def _check_binfmt_misc(executable
):
158 """Check binfmt_misc has entry for executable in the right place.
160 The details of setting up binfmt_misc are outside the scope of
161 this script but we should at least fail early with a useful
162 message if it won't work.
164 Returns the configured binfmt path and a valid flag. For
165 persistent configurations we will still want to copy and dependent
169 binary
= os
.path
.basename(executable
)
170 binfmt_entry
= "/proc/sys/fs/binfmt_misc/%s" % (binary
)
172 if not os
.path
.exists(binfmt_entry
):
173 print ("No binfmt_misc entry for %s" % (binary
))
176 with
open(binfmt_entry
) as x
: entry
= x
.read()
178 if re
.search("flags:.*F.*\n", entry
):
179 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
183 m
= re
.search("interpreter (\S+)\n", entry
)
185 if interp
and interp
!= executable
:
186 print("binfmt_misc for %s does not point to %s, using %s" %
187 (binary
, executable
, interp
))
192 def _read_qemu_dockerfile(img_name
):
193 # special case for Debian linux-user images
194 if img_name
.startswith("debian") and img_name
.endswith("user"):
195 img_name
= "debian-bootstrap"
197 df
= os
.path
.join(os
.path
.dirname(__file__
), "dockerfiles",
198 img_name
+ ".docker")
199 return _read_dockerfile(df
)
202 def _dockerfile_preprocess(df
):
204 for l
in df
.splitlines():
205 if len(l
.strip()) == 0 or l
.startswith("#"):
207 from_pref
= "FROM qemu:"
208 if l
.startswith(from_pref
):
209 # TODO: Alternatively we could replace this line with "FROM $ID"
210 # where $ID is the image's hex id obtained with
211 # $ docker images $IMAGE --format="{{.Id}}"
212 # but unfortunately that's not supported by RHEL 7.
213 inlining
= _read_qemu_dockerfile(l
[len(from_pref
):])
214 out
+= _dockerfile_preprocess(inlining
)
220 class Docker(object):
221 """ Running Docker commands """
223 self
._command
= _guess_engine_command()
224 self
._instance
= None
225 atexit
.register(self
._kill
_instances
)
226 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
227 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
229 def _do(self
, cmd
, quiet
=True, **kwargs
):
231 kwargs
["stdout"] = DEVNULL
232 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
234 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
236 kwargs
["stdout"] = DEVNULL
237 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
239 def _do_kill_instances(self
, only_known
, only_active
=True):
244 filter = "--filter=label=com.qemu.instance.uuid"
247 filter += "=%s" % (self
._instance
)
249 # no point trying to kill, we finished
252 print("filter=%s" % (filter))
254 for i
in self
._output
(cmd
).split():
255 self
._do
(["rm", "-f", i
])
258 self
._do
_kill
_instances
(False, False)
261 def _kill_instances(self
, *args
, **kwargs
):
262 return self
._do
_kill
_instances
(True)
264 def _output(self
, cmd
, **kwargs
):
266 return subprocess
.check_output(self
._command
+ cmd
,
267 stderr
=subprocess
.STDOUT
,
271 # 'encoding' argument was added in 3.6+
272 return subprocess
.check_output(self
._command
+ cmd
,
273 stderr
=subprocess
.STDOUT
,
274 **kwargs
).decode('utf-8')
277 def inspect_tag(self
, tag
):
279 return self
._output
(["inspect", tag
])
280 except subprocess
.CalledProcessError
:
283 def get_image_creation_time(self
, info
):
284 return json
.loads(info
)[0]["Created"]
286 def get_image_dockerfile_checksum(self
, tag
):
287 resp
= self
.inspect_tag(tag
)
288 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
289 return labels
.get("com.qemu.dockerfile-checksum", "")
291 def build_image(self
, tag
, docker_dir
, dockerfile
,
292 quiet
=True, user
=False, argv
=None, extra_files_cksum
=[]):
296 tmp_df
= tempfile
.NamedTemporaryFile(mode
="w+t",
298 dir=docker_dir
, suffix
=".docker")
299 tmp_df
.write(dockerfile
)
303 uname
= getpwuid(uid
).pw_name
305 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
309 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s" %
310 _text_checksum(_dockerfile_preprocess(dockerfile
)))
311 for f
, c
in extra_files_cksum
:
312 tmp_df
.write("LABEL com.qemu.%s-checksum=%s" % (f
, c
))
316 self
._do
_check
(["build", "-t", tag
, "-f", tmp_df
.name
] + argv
+
320 def update_image(self
, tag
, tarball
, quiet
=True):
321 "Update a tagged image using "
323 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
325 def image_matches_dockerfile(self
, tag
, dockerfile
):
327 checksum
= self
.get_image_dockerfile_checksum(tag
)
330 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
332 def run(self
, cmd
, keep
, quiet
, as_user
=False):
333 label
= uuid
.uuid4().hex
335 self
._instance
= label
339 cmd
= [ "-u", str(uid
) ] + cmd
340 # podman requires a bit more fiddling
341 if self
._command
[0] == "podman":
342 cmd
.insert(0, '--userns=keep-id')
344 ret
= self
._do
_check
(["run", "--label",
345 "com.qemu.instance.uuid=" + label
] + cmd
,
348 self
._instance
= None
351 def command(self
, cmd
, argv
, quiet
):
352 return self
._do
([cmd
] + argv
, quiet
=quiet
)
355 class SubCommand(object):
356 """A SubCommand template base class"""
357 name
= None # Subcommand name
359 def shared_args(self
, parser
):
360 parser
.add_argument("--quiet", action
="store_true",
361 help="Run quietly unless an error occurred")
363 def args(self
, parser
):
364 """Setup argument parser"""
367 def run(self
, args
, argv
):
369 args: parsed argument by argument parser.
370 argv: remaining arguments from sys.argv.
375 class RunCommand(SubCommand
):
376 """Invoke docker run and take care of cleaning up"""
379 def args(self
, parser
):
380 parser
.add_argument("--keep", action
="store_true",
381 help="Don't remove image when command completes")
382 parser
.add_argument("--run-as-current-user", action
="store_true",
383 help="Run container using the current user's uid")
385 def run(self
, args
, argv
):
386 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
,
387 as_user
=args
.run_as_current_user
)
390 class BuildCommand(SubCommand
):
391 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
394 def args(self
, parser
):
395 parser
.add_argument("--include-executable", "-e",
396 help="""Specify a binary that will be copied to the
397 container together with all its dependent
399 parser
.add_argument("--extra-files", nargs
='*',
400 help="""Specify files that will be copied in the
401 Docker image, fulfilling the ADD directive from the
403 parser
.add_argument("--add-current-user", "-u", dest
="user",
405 help="Add the current user to image's passwd")
406 parser
.add_argument("-t", dest
="tag",
408 parser
.add_argument("-f", dest
="dockerfile",
409 help="Dockerfile name")
411 def run(self
, args
, argv
):
412 dockerfile
= _read_dockerfile(args
.dockerfile
)
416 if "--no-cache" not in argv
and \
417 dkr
.image_matches_dockerfile(tag
, dockerfile
):
419 print("Image is up to date.")
421 # Create a docker context directory for the build
422 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
424 # Validate binfmt_misc will work
425 if args
.include_executable
:
426 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
430 # Is there a .pre file to run in the build context?
431 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
432 if os
.path
.exists(docker_pre
):
433 stdout
= DEVNULL
if args
.quiet
else None
434 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
435 cwd
=docker_dir
, stdout
=stdout
)
440 print("%s exited with code %d" % (docker_pre
, rc
))
443 # Copy any extra files into the Docker context. These can be
444 # included by the use of the ADD directive in the Dockerfile.
446 if args
.include_executable
:
447 # FIXME: there is no checksum of this executable and the linked
448 # libraries, once the image built any change of this executable
449 # or any library won't trigger another build.
450 _copy_binary_with_libs(args
.include_executable
,
453 for filename
in args
.extra_files
or []:
454 _copy_with_mkdir(filename
, docker_dir
)
455 cksum
+= [(filename
, _file_checksum(filename
))]
457 argv
+= ["--build-arg=" + k
.lower() + "=" + v
458 for k
, v
in os
.environ
.items()
459 if k
.lower() in FILTERED_ENV_NAMES
]
460 dkr
.build_image(tag
, docker_dir
, dockerfile
,
461 quiet
=args
.quiet
, user
=args
.user
, argv
=argv
,
462 extra_files_cksum
=cksum
)
469 class UpdateCommand(SubCommand
):
470 """ Update a docker image with new executables. Args: <tag> <executable>"""
473 def args(self
, parser
):
474 parser
.add_argument("tag",
476 parser
.add_argument("executable",
477 help="Executable to copy")
479 def run(self
, args
, argv
):
480 # Create a temporary tarball with our whole build context and
481 # dockerfile for the update
482 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
483 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
485 # Add the executable to the tarball, using the current
486 # configured binfmt_misc path. If we don't get a path then we
487 # only need the support libraries copied
488 ff
, enabled
= _check_binfmt_misc(args
.executable
)
491 print("binfmt_misc not enabled, update disabled")
495 tmp_tar
.add(args
.executable
, arcname
=ff
)
497 # Add any associated libraries
498 libs
= _get_so_libs(args
.executable
)
501 tmp_tar
.add(os
.path
.realpath(l
), arcname
=l
)
503 # Create a Docker buildfile
505 df
.write("FROM %s\n" % args
.tag
)
506 df
.write("ADD . /\n")
509 df_tar
= TarInfo(name
="Dockerfile")
510 df_tar
.size
= len(df
.buf
)
511 tmp_tar
.addfile(df_tar
, fileobj
=df
)
515 # reset the file pointers
519 # Run the build with our tarball context
521 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
526 class CleanCommand(SubCommand
):
527 """Clean up docker instances"""
530 def run(self
, args
, argv
):
535 class ImagesCommand(SubCommand
):
536 """Run "docker images" command"""
539 def run(self
, args
, argv
):
540 return Docker().command("images", argv
, args
.quiet
)
543 class ProbeCommand(SubCommand
):
544 """Probe if we can run docker automatically"""
547 def run(self
, args
, argv
):
550 if docker
._command
[0] == "docker":
552 elif docker
._command
[0] == "sudo":
554 elif docker
._command
[0] == "podman":
562 class CcCommand(SubCommand
):
563 """Compile sources with cc in images"""
566 def args(self
, parser
):
567 parser
.add_argument("--image", "-i", required
=True,
568 help="The docker image in which to run cc")
569 parser
.add_argument("--cc", default
="cc",
570 help="The compiler executable to call")
571 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
572 help="""Extra paths to (ro) mount into container for
575 def run(self
, args
, argv
):
576 if argv
and argv
[0] == "--":
579 cmd
= ["--rm", "-w", cwd
,
580 "-v", "%s:%s:rw" % (cwd
, cwd
)]
583 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
584 cmd
+= [args
.image
, args
.cc
]
586 return Docker().run(cmd
, False, quiet
=args
.quiet
,
590 class CheckCommand(SubCommand
):
591 """Check if we need to re-build a docker image out of a dockerfile.
592 Arguments: <tag> <dockerfile>"""
595 def args(self
, parser
):
596 parser
.add_argument("tag",
598 parser
.add_argument("dockerfile", default
=None,
599 help="Dockerfile name", nargs
='?')
600 parser
.add_argument("--checktype", choices
=["checksum", "age"],
601 default
="checksum", help="check type")
602 parser
.add_argument("--olderthan", default
=60, type=int,
603 help="number of minutes")
605 def run(self
, args
, argv
):
610 except subprocess
.CalledProcessError
:
611 print("Docker not set up")
614 info
= dkr
.inspect_tag(tag
)
616 print("Image does not exist")
619 if args
.checktype
== "checksum":
620 if not args
.dockerfile
:
621 print("Need a dockerfile for tag:%s" % (tag
))
624 dockerfile
= _read_dockerfile(args
.dockerfile
)
626 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
628 print("Image is up to date")
631 print("Image needs updating")
633 elif args
.checktype
== "age":
634 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
635 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
636 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
638 print ("Image created @ %s more than %d minutes old" %
639 (timestr
, args
.olderthan
))
643 print ("Image less than %d minutes old" % (args
.olderthan
))
650 parser
= argparse
.ArgumentParser(description
="A Docker helper",
651 usage
="%s <subcommand> ..." %
652 os
.path
.basename(sys
.argv
[0]))
653 parser
.add_argument("--engine", type=EngineEnum
.argparse
, choices
=list(EngineEnum
),
654 help="specify which container engine to use")
655 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
656 for cls
in SubCommand
.__subclasses
__():
658 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
659 cmd
.shared_args(subp
)
661 subp
.set_defaults(cmdobj
=cmd
)
662 args
, argv
= parser
.parse_known_args()
664 USE_ENGINE
= args
.engine
665 return args
.cmdobj
.run(args
, argv
)
668 if __name__
== "__main__":