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 _text_checksum(text
):
60 """Calculate a digest string unique to the text content"""
61 return hashlib
.sha1(text
.encode('utf-8')).hexdigest()
63 def _read_dockerfile(path
):
64 return open(path
, 'rt', encoding
='utf-8').read()
66 def _file_checksum(filename
):
67 return _text_checksum(_read_dockerfile(filename
))
70 def _guess_engine_command():
71 """ Guess a working engine command or raise exception if not found"""
74 if USE_ENGINE
in [EngineEnum
.AUTO
, EngineEnum
.PODMAN
]:
75 commands
+= [["podman"]]
76 if USE_ENGINE
in [EngineEnum
.AUTO
, EngineEnum
.DOCKER
]:
77 commands
+= [["docker"], ["sudo", "-n", "docker"]]
80 # docker version will return the client details in stdout
81 # but still report a status of 1 if it can't contact the daemon
82 if subprocess
.call(cmd
+ ["version"],
83 stdout
=DEVNULL
, stderr
=DEVNULL
) == 0:
87 commands_txt
= "\n".join([" " + " ".join(x
) for x
in commands
])
88 raise Exception("Cannot find working engine command. Tried:\n%s" %
92 def _copy_with_mkdir(src
, root_dir
, sub_path
='.'):
93 """Copy src into root_dir, creating sub_path as needed."""
94 dest_dir
= os
.path
.normpath("%s/%s" % (root_dir
, sub_path
))
98 # we can safely ignore already created directories
101 dest_file
= "%s/%s" % (dest_dir
, os
.path
.basename(src
))
105 def _get_so_libs(executable
):
106 """Return a list of libraries associated with an executable.
108 The paths may be symbolic links which would need to be resolved to
109 ensure theright data is copied."""
112 ldd_re
= re
.compile(r
"(/.*/)(\S*)")
114 ldd_output
= subprocess
.check_output(["ldd", executable
]).decode('utf-8')
115 for line
in ldd_output
.split("\n"):
116 search
= ldd_re
.search(line
)
117 if search
and len(search
.groups()) == 2:
118 so_path
= search
.groups()[0]
119 so_lib
= search
.groups()[1]
120 libs
.append("%s/%s" % (so_path
, so_lib
))
121 except subprocess
.CalledProcessError
:
122 print("%s had no associated libraries (static build?)" % (executable
))
127 def _copy_binary_with_libs(src
, bin_dest
, dest_dir
):
128 """Maybe copy a binary and all its dependent libraries.
130 If bin_dest isn't set we only copy the support libraries because
131 we don't need qemu in the docker path to run (due to persistent
132 mapping). Indeed users may get confused if we aren't running what
135 This does rely on the host file-system being fairly multi-arch
136 aware so the file don't clash with the guests layout.
140 _copy_with_mkdir(src
, dest_dir
, os
.path
.dirname(bin_dest
))
142 print("only copying support libraries for %s" % (src
))
144 libs
= _get_so_libs(src
)
147 so_path
= os
.path
.dirname(l
)
148 _copy_with_mkdir(l
, dest_dir
, so_path
)
151 def _check_binfmt_misc(executable
):
152 """Check binfmt_misc has entry for executable in the right place.
154 The details of setting up binfmt_misc are outside the scope of
155 this script but we should at least fail early with a useful
156 message if it won't work.
158 Returns the configured binfmt path and a valid flag. For
159 persistent configurations we will still want to copy and dependent
163 binary
= os
.path
.basename(executable
)
164 binfmt_entry
= "/proc/sys/fs/binfmt_misc/%s" % (binary
)
166 if not os
.path
.exists(binfmt_entry
):
167 print ("No binfmt_misc entry for %s" % (binary
))
170 with
open(binfmt_entry
) as x
: entry
= x
.read()
172 if re
.search("flags:.*F.*\n", entry
):
173 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
177 m
= re
.search("interpreter (\S+)\n", entry
)
179 if interp
and interp
!= executable
:
180 print("binfmt_misc for %s does not point to %s, using %s" %
181 (binary
, executable
, interp
))
186 def _read_qemu_dockerfile(img_name
):
187 # special case for Debian linux-user images
188 if img_name
.startswith("debian") and img_name
.endswith("user"):
189 img_name
= "debian-bootstrap"
191 df
= os
.path
.join(os
.path
.dirname(__file__
), "dockerfiles",
192 img_name
+ ".docker")
193 return _read_dockerfile(df
)
196 def _dockerfile_preprocess(df
):
198 for l
in df
.splitlines():
199 if len(l
.strip()) == 0 or l
.startswith("#"):
201 from_pref
= "FROM qemu:"
202 if l
.startswith(from_pref
):
203 # TODO: Alternatively we could replace this line with "FROM $ID"
204 # where $ID is the image's hex id obtained with
205 # $ docker images $IMAGE --format="{{.Id}}"
206 # but unfortunately that's not supported by RHEL 7.
207 inlining
= _read_qemu_dockerfile(l
[len(from_pref
):])
208 out
+= _dockerfile_preprocess(inlining
)
214 class Docker(object):
215 """ Running Docker commands """
217 self
._command
= _guess_engine_command()
218 self
._instance
= None
219 atexit
.register(self
._kill
_instances
)
220 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
221 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
223 def _do(self
, cmd
, quiet
=True, **kwargs
):
225 kwargs
["stdout"] = DEVNULL
226 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
228 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
230 kwargs
["stdout"] = DEVNULL
231 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
233 def _do_kill_instances(self
, only_known
, only_active
=True):
238 filter = "--filter=label=com.qemu.instance.uuid"
241 filter += "=%s" % (self
._instance
)
243 # no point trying to kill, we finished
246 print("filter=%s" % (filter))
248 for i
in self
._output
(cmd
).split():
249 self
._do
(["rm", "-f", i
])
252 self
._do
_kill
_instances
(False, False)
255 def _kill_instances(self
, *args
, **kwargs
):
256 return self
._do
_kill
_instances
(True)
258 def _output(self
, cmd
, **kwargs
):
259 if sys
.version_info
[1] >= 6:
260 return subprocess
.check_output(self
._command
+ cmd
,
261 stderr
=subprocess
.STDOUT
,
265 return subprocess
.check_output(self
._command
+ cmd
,
266 stderr
=subprocess
.STDOUT
,
267 **kwargs
).decode('utf-8')
270 def inspect_tag(self
, tag
):
272 return self
._output
(["inspect", tag
])
273 except subprocess
.CalledProcessError
:
276 def get_image_creation_time(self
, info
):
277 return json
.loads(info
)[0]["Created"]
279 def get_image_dockerfile_checksum(self
, tag
):
280 resp
= self
.inspect_tag(tag
)
281 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
282 return labels
.get("com.qemu.dockerfile-checksum", "")
284 def build_image(self
, tag
, docker_dir
, dockerfile
,
285 quiet
=True, user
=False, argv
=None, extra_files_cksum
=[]):
289 tmp_df
= tempfile
.NamedTemporaryFile(mode
="w+t",
291 dir=docker_dir
, suffix
=".docker")
292 tmp_df
.write(dockerfile
)
296 uname
= getpwuid(uid
).pw_name
298 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
302 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s" %
303 _text_checksum(_dockerfile_preprocess(dockerfile
)))
304 for f
, c
in extra_files_cksum
:
305 tmp_df
.write("LABEL com.qemu.%s-checksum=%s" % (f
, c
))
309 self
._do
_check
(["build", "-t", tag
, "-f", tmp_df
.name
] + argv
+
313 def update_image(self
, tag
, tarball
, quiet
=True):
314 "Update a tagged image using "
316 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
318 def image_matches_dockerfile(self
, tag
, dockerfile
):
320 checksum
= self
.get_image_dockerfile_checksum(tag
)
323 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
325 def run(self
, cmd
, keep
, quiet
, as_user
=False):
326 label
= uuid
.uuid4().hex
328 self
._instance
= label
332 cmd
= [ "-u", str(uid
) ] + cmd
333 # podman requires a bit more fiddling
334 if self
._command
[0] == "podman":
335 cmd
.insert(0, '--userns=keep-id')
337 ret
= self
._do
_check
(["run", "--label",
338 "com.qemu.instance.uuid=" + label
] + cmd
,
341 self
._instance
= None
344 def command(self
, cmd
, argv
, quiet
):
345 return self
._do
([cmd
] + argv
, quiet
=quiet
)
348 class SubCommand(object):
349 """A SubCommand template base class"""
350 name
= None # Subcommand name
352 def shared_args(self
, parser
):
353 parser
.add_argument("--quiet", action
="store_true",
354 help="Run quietly unless an error occurred")
356 def args(self
, parser
):
357 """Setup argument parser"""
360 def run(self
, args
, argv
):
362 args: parsed argument by argument parser.
363 argv: remaining arguments from sys.argv.
368 class RunCommand(SubCommand
):
369 """Invoke docker run and take care of cleaning up"""
372 def args(self
, parser
):
373 parser
.add_argument("--keep", action
="store_true",
374 help="Don't remove image when command completes")
375 parser
.add_argument("--run-as-current-user", action
="store_true",
376 help="Run container using the current user's uid")
378 def run(self
, args
, argv
):
379 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
,
380 as_user
=args
.run_as_current_user
)
383 class BuildCommand(SubCommand
):
384 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
387 def args(self
, parser
):
388 parser
.add_argument("--include-executable", "-e",
389 help="""Specify a binary that will be copied to the
390 container together with all its dependent
392 parser
.add_argument("--extra-files", "-f", nargs
='*',
393 help="""Specify files that will be copied in the
394 Docker image, fulfilling the ADD directive from the
396 parser
.add_argument("--add-current-user", "-u", dest
="user",
398 help="Add the current user to image's passwd")
399 parser
.add_argument("tag",
401 parser
.add_argument("dockerfile",
402 help="Dockerfile name")
404 def run(self
, args
, argv
):
405 dockerfile
= _read_dockerfile(args
.dockerfile
)
409 if "--no-cache" not in argv
and \
410 dkr
.image_matches_dockerfile(tag
, dockerfile
):
412 print("Image is up to date.")
414 # Create a docker context directory for the build
415 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
417 # Validate binfmt_misc will work
418 if args
.include_executable
:
419 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
423 # Is there a .pre file to run in the build context?
424 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
425 if os
.path
.exists(docker_pre
):
426 stdout
= DEVNULL
if args
.quiet
else None
427 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
428 cwd
=docker_dir
, stdout
=stdout
)
433 print("%s exited with code %d" % (docker_pre
, rc
))
436 # Copy any extra files into the Docker context. These can be
437 # included by the use of the ADD directive in the Dockerfile.
439 if args
.include_executable
:
440 # FIXME: there is no checksum of this executable and the linked
441 # libraries, once the image built any change of this executable
442 # or any library won't trigger another build.
443 _copy_binary_with_libs(args
.include_executable
,
446 for filename
in args
.extra_files
or []:
447 _copy_with_mkdir(filename
, docker_dir
)
448 cksum
+= [(filename
, _file_checksum(filename
))]
450 argv
+= ["--build-arg=" + k
.lower() + "=" + v
451 for k
, v
in os
.environ
.items()
452 if k
.lower() in FILTERED_ENV_NAMES
]
453 dkr
.build_image(tag
, docker_dir
, dockerfile
,
454 quiet
=args
.quiet
, user
=args
.user
, argv
=argv
,
455 extra_files_cksum
=cksum
)
462 class UpdateCommand(SubCommand
):
463 """ Update a docker image with new executables. Args: <tag> <executable>"""
466 def args(self
, parser
):
467 parser
.add_argument("tag",
469 parser
.add_argument("executable",
470 help="Executable to copy")
472 def run(self
, args
, argv
):
473 # Create a temporary tarball with our whole build context and
474 # dockerfile for the update
475 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
476 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
478 # Add the executable to the tarball, using the current
479 # configured binfmt_misc path. If we don't get a path then we
480 # only need the support libraries copied
481 ff
, enabled
= _check_binfmt_misc(args
.executable
)
484 print("binfmt_misc not enabled, update disabled")
488 tmp_tar
.add(args
.executable
, arcname
=ff
)
490 # Add any associated libraries
491 libs
= _get_so_libs(args
.executable
)
494 tmp_tar
.add(os
.path
.realpath(l
), arcname
=l
)
496 # Create a Docker buildfile
498 df
.write("FROM %s\n" % args
.tag
)
499 df
.write("ADD . /\n")
502 df_tar
= TarInfo(name
="Dockerfile")
503 df_tar
.size
= len(df
.buf
)
504 tmp_tar
.addfile(df_tar
, fileobj
=df
)
508 # reset the file pointers
512 # Run the build with our tarball context
514 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
519 class CleanCommand(SubCommand
):
520 """Clean up docker instances"""
523 def run(self
, args
, argv
):
528 class ImagesCommand(SubCommand
):
529 """Run "docker images" command"""
532 def run(self
, args
, argv
):
533 return Docker().command("images", argv
, args
.quiet
)
536 class ProbeCommand(SubCommand
):
537 """Probe if we can run docker automatically"""
540 def run(self
, args
, argv
):
543 if docker
._command
[0] == "docker":
545 elif docker
._command
[0] == "sudo":
547 elif docker
._command
[0] == "podman":
555 class CcCommand(SubCommand
):
556 """Compile sources with cc in images"""
559 def args(self
, parser
):
560 parser
.add_argument("--image", "-i", required
=True,
561 help="The docker image in which to run cc")
562 parser
.add_argument("--cc", default
="cc",
563 help="The compiler executable to call")
564 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
565 help="""Extra paths to (ro) mount into container for
568 def run(self
, args
, argv
):
569 if argv
and argv
[0] == "--":
572 cmd
= ["--rm", "-w", cwd
,
573 "-v", "%s:%s:rw" % (cwd
, cwd
)]
576 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
577 cmd
+= [args
.image
, args
.cc
]
579 return Docker().run(cmd
, False, quiet
=args
.quiet
,
583 class CheckCommand(SubCommand
):
584 """Check if we need to re-build a docker image out of a dockerfile.
585 Arguments: <tag> <dockerfile>"""
588 def args(self
, parser
):
589 parser
.add_argument("tag",
591 parser
.add_argument("dockerfile", default
=None,
592 help="Dockerfile name", nargs
='?')
593 parser
.add_argument("--checktype", choices
=["checksum", "age"],
594 default
="checksum", help="check type")
595 parser
.add_argument("--olderthan", default
=60, type=int,
596 help="number of minutes")
598 def run(self
, args
, argv
):
603 except subprocess
.CalledProcessError
:
604 print("Docker not set up")
607 info
= dkr
.inspect_tag(tag
)
609 print("Image does not exist")
612 if args
.checktype
== "checksum":
613 if not args
.dockerfile
:
614 print("Need a dockerfile for tag:%s" % (tag
))
617 dockerfile
= _read_dockerfile(args
.dockerfile
)
619 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
621 print("Image is up to date")
624 print("Image needs updating")
626 elif args
.checktype
== "age":
627 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
628 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
629 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
631 print ("Image created @ %s more than %d minutes old" %
632 (timestr
, args
.olderthan
))
636 print ("Image less than %d minutes old" % (args
.olderthan
))
643 parser
= argparse
.ArgumentParser(description
="A Docker helper",
644 usage
="%s <subcommand> ..." %
645 os
.path
.basename(sys
.argv
[0]))
646 parser
.add_argument("--engine", type=EngineEnum
.argparse
, choices
=list(EngineEnum
),
647 help="specify which container engine to use")
648 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
649 for cls
in SubCommand
.__subclasses
__():
651 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
652 cmd
.shared_args(subp
)
654 subp
.set_defaults(cmdobj
=cmd
)
655 args
, argv
= parser
.parse_known_args()
657 USE_ENGINE
= args
.engine
658 return args
.cmdobj
.run(args
, argv
)
661 if __name__
== "__main__":