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
, BytesIO
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
='.', name
=None):
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
, name
if name
else os
.path
.basename(src
))
109 except FileNotFoundError
:
110 print("Couldn't copy %s to %s" % (src
, dest_file
))
114 def _get_so_libs(executable
):
115 """Return a list of libraries associated with an executable.
117 The paths may be symbolic links which would need to be resolved to
118 ensure the right data is copied."""
121 ldd_re
= re
.compile(r
"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)")
123 ldd_output
= subprocess
.check_output(["ldd", executable
]).decode('utf-8')
124 for line
in ldd_output
.split("\n"):
125 search
= ldd_re
.search(line
)
128 libs
.append(search
.group(1))
131 except subprocess
.CalledProcessError
:
132 print("%s had no associated libraries (static build?)" % (executable
))
137 def _copy_binary_with_libs(src
, bin_dest
, dest_dir
):
138 """Maybe copy a binary and all its dependent libraries.
140 If bin_dest isn't set we only copy the support libraries because
141 we don't need qemu in the docker path to run (due to persistent
142 mapping). Indeed users may get confused if we aren't running what
145 This does rely on the host file-system being fairly multi-arch
146 aware so the file don't clash with the guests layout.
150 _copy_with_mkdir(src
, dest_dir
, os
.path
.dirname(bin_dest
))
152 print("only copying support libraries for %s" % (src
))
154 libs
= _get_so_libs(src
)
157 so_path
= os
.path
.dirname(l
)
158 name
= os
.path
.basename(l
)
159 real_l
= os
.path
.realpath(l
)
160 _copy_with_mkdir(real_l
, dest_dir
, so_path
, name
)
163 def _check_binfmt_misc(executable
):
164 """Check binfmt_misc has entry for executable in the right place.
166 The details of setting up binfmt_misc are outside the scope of
167 this script but we should at least fail early with a useful
168 message if it won't work.
170 Returns the configured binfmt path and a valid flag. For
171 persistent configurations we will still want to copy and dependent
175 binary
= os
.path
.basename(executable
)
176 binfmt_entry
= "/proc/sys/fs/binfmt_misc/%s" % (binary
)
178 if not os
.path
.exists(binfmt_entry
):
179 print ("No binfmt_misc entry for %s" % (binary
))
182 with
open(binfmt_entry
) as x
: entry
= x
.read()
184 if re
.search("flags:.*F.*\n", entry
):
185 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
189 m
= re
.search("interpreter (\S+)\n", entry
)
191 if interp
and interp
!= executable
:
192 print("binfmt_misc for %s does not point to %s, using %s" %
193 (binary
, executable
, interp
))
198 def _read_qemu_dockerfile(img_name
):
199 # special case for Debian linux-user images
200 if img_name
.startswith("debian") and img_name
.endswith("user"):
201 img_name
= "debian-bootstrap"
203 df
= os
.path
.join(os
.path
.dirname(__file__
), "dockerfiles",
204 img_name
+ ".docker")
205 return _read_dockerfile(df
)
208 def _dockerfile_preprocess(df
):
210 for l
in df
.splitlines():
211 if len(l
.strip()) == 0 or l
.startswith("#"):
213 from_pref
= "FROM qemu/"
214 if l
.startswith(from_pref
):
215 # TODO: Alternatively we could replace this line with "FROM $ID"
216 # where $ID is the image's hex id obtained with
217 # $ docker images $IMAGE --format="{{.Id}}"
218 # but unfortunately that's not supported by RHEL 7.
219 inlining
= _read_qemu_dockerfile(l
[len(from_pref
):])
220 out
+= _dockerfile_preprocess(inlining
)
226 class Docker(object):
227 """ Running Docker commands """
229 self
._command
= _guess_engine_command()
231 if "docker" in self
._command
and "TRAVIS" not in os
.environ
:
232 os
.environ
["DOCKER_BUILDKIT"] = "1"
233 self
._buildkit
= True
235 self
._buildkit
= False
237 self
._instance
= None
238 atexit
.register(self
._kill
_instances
)
239 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
240 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
242 def _do(self
, cmd
, quiet
=True, **kwargs
):
244 kwargs
["stdout"] = DEVNULL
245 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
247 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
249 kwargs
["stdout"] = DEVNULL
250 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
252 def _do_kill_instances(self
, only_known
, only_active
=True):
257 filter = "--filter=label=com.qemu.instance.uuid"
260 filter += "=%s" % (self
._instance
)
262 # no point trying to kill, we finished
265 print("filter=%s" % (filter))
267 for i
in self
._output
(cmd
).split():
268 self
._do
(["rm", "-f", i
])
271 self
._do
_kill
_instances
(False, False)
274 def _kill_instances(self
, *args
, **kwargs
):
275 return self
._do
_kill
_instances
(True)
277 def _output(self
, cmd
, **kwargs
):
279 return subprocess
.check_output(self
._command
+ cmd
,
280 stderr
=subprocess
.STDOUT
,
284 # 'encoding' argument was added in 3.6+
285 return subprocess
.check_output(self
._command
+ cmd
,
286 stderr
=subprocess
.STDOUT
,
287 **kwargs
).decode('utf-8')
290 def inspect_tag(self
, tag
):
292 return self
._output
(["inspect", tag
])
293 except subprocess
.CalledProcessError
:
296 def get_image_creation_time(self
, info
):
297 return json
.loads(info
)[0]["Created"]
299 def get_image_dockerfile_checksum(self
, tag
):
300 resp
= self
.inspect_tag(tag
)
301 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
302 return labels
.get("com.qemu.dockerfile-checksum", "")
304 def build_image(self
, tag
, docker_dir
, dockerfile
,
305 quiet
=True, user
=False, argv
=None, registry
=None,
306 extra_files_cksum
=[]):
310 # pre-calculate the docker checksum before any
311 # substitutions we make for caching
312 checksum
= _text_checksum(_dockerfile_preprocess(dockerfile
))
314 if registry
is not None:
315 sources
= re
.findall("FROM qemu\/(.*)", dockerfile
)
316 # Fetch any cache layers we can, may fail
318 pull_args
= ["pull", "%s/qemu/%s" % (registry
, s
)]
319 if self
._do
(pull_args
, quiet
=quiet
) != 0:
323 if registry
is not None:
324 dockerfile
= dockerfile
.replace("FROM qemu/",
328 tmp_df
= tempfile
.NamedTemporaryFile(mode
="w+t",
330 dir=docker_dir
, suffix
=".docker")
331 tmp_df
.write(dockerfile
)
335 uname
= getpwuid(uid
).pw_name
337 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
341 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum
))
342 for f
, c
in extra_files_cksum
:
343 tmp_df
.write("LABEL com.qemu.%s-checksum=%s\n" % (f
, c
))
347 build_args
= ["build", "-t", tag
, "-f", tmp_df
.name
]
349 build_args
+= ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
351 if registry
is not None:
352 pull_args
= ["pull", "%s/%s" % (registry
, tag
)]
353 self
._do
(pull_args
, quiet
=quiet
)
354 cache
= "%s/%s" % (registry
, tag
)
355 build_args
+= ["--cache-from", cache
]
357 build_args
+= [docker_dir
]
359 self
._do
_check
(build_args
,
362 def update_image(self
, tag
, tarball
, quiet
=True):
363 "Update a tagged image using "
365 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
367 def image_matches_dockerfile(self
, tag
, dockerfile
):
369 checksum
= self
.get_image_dockerfile_checksum(tag
)
372 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
374 def run(self
, cmd
, keep
, quiet
, as_user
=False):
375 label
= uuid
.uuid4().hex
377 self
._instance
= label
381 cmd
= [ "-u", str(uid
) ] + cmd
382 # podman requires a bit more fiddling
383 if self
._command
[0] == "podman":
384 cmd
.insert(0, '--userns=keep-id')
386 ret
= self
._do
_check
(["run", "--rm", "--label",
387 "com.qemu.instance.uuid=" + label
] + cmd
,
390 self
._instance
= None
393 def command(self
, cmd
, argv
, quiet
):
394 return self
._do
([cmd
] + argv
, quiet
=quiet
)
397 class SubCommand(object):
398 """A SubCommand template base class"""
399 name
= None # Subcommand name
401 def shared_args(self
, parser
):
402 parser
.add_argument("--quiet", action
="store_true",
403 help="Run quietly unless an error occurred")
405 def args(self
, parser
):
406 """Setup argument parser"""
409 def run(self
, args
, argv
):
411 args: parsed argument by argument parser.
412 argv: remaining arguments from sys.argv.
417 class RunCommand(SubCommand
):
418 """Invoke docker run and take care of cleaning up"""
421 def args(self
, parser
):
422 parser
.add_argument("--keep", action
="store_true",
423 help="Don't remove image when command completes")
424 parser
.add_argument("--run-as-current-user", action
="store_true",
425 help="Run container using the current user's uid")
427 def run(self
, args
, argv
):
428 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
,
429 as_user
=args
.run_as_current_user
)
432 class BuildCommand(SubCommand
):
433 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
436 def args(self
, parser
):
437 parser
.add_argument("--include-executable", "-e",
438 help="""Specify a binary that will be copied to the
439 container together with all its dependent
441 parser
.add_argument("--skip-binfmt",
443 help="""Skip binfmt entry check (used for testing)""")
444 parser
.add_argument("--extra-files", nargs
='*',
445 help="""Specify files that will be copied in the
446 Docker image, fulfilling the ADD directive from the
448 parser
.add_argument("--add-current-user", "-u", dest
="user",
450 help="Add the current user to image's passwd")
451 parser
.add_argument("--registry", "-r",
452 help="cache from docker registry")
453 parser
.add_argument("-t", dest
="tag",
455 parser
.add_argument("-f", dest
="dockerfile",
456 help="Dockerfile name")
458 def run(self
, args
, argv
):
459 dockerfile
= _read_dockerfile(args
.dockerfile
)
463 if "--no-cache" not in argv
and \
464 dkr
.image_matches_dockerfile(tag
, dockerfile
):
466 print("Image is up to date.")
468 # Create a docker context directory for the build
469 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
471 # Validate binfmt_misc will work
473 qpath
= args
.include_executable
474 elif args
.include_executable
:
475 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
479 # Is there a .pre file to run in the build context?
480 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
481 if os
.path
.exists(docker_pre
):
482 stdout
= DEVNULL
if args
.quiet
else None
483 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
484 cwd
=docker_dir
, stdout
=stdout
)
489 print("%s exited with code %d" % (docker_pre
, rc
))
492 # Copy any extra files into the Docker context. These can be
493 # included by the use of the ADD directive in the Dockerfile.
495 if args
.include_executable
:
496 # FIXME: there is no checksum of this executable and the linked
497 # libraries, once the image built any change of this executable
498 # or any library won't trigger another build.
499 _copy_binary_with_libs(args
.include_executable
,
502 for filename
in args
.extra_files
or []:
503 _copy_with_mkdir(filename
, docker_dir
)
504 cksum
+= [(filename
, _file_checksum(filename
))]
506 argv
+= ["--build-arg=" + k
.lower() + "=" + v
507 for k
, v
in os
.environ
.items()
508 if k
.lower() in FILTERED_ENV_NAMES
]
509 dkr
.build_image(tag
, docker_dir
, dockerfile
,
510 quiet
=args
.quiet
, user
=args
.user
,
511 argv
=argv
, registry
=args
.registry
,
512 extra_files_cksum
=cksum
)
518 class FetchCommand(SubCommand
):
519 """ Fetch a docker image from the registry. Args: <tag> <registry>"""
522 def args(self
, parser
):
523 parser
.add_argument("tag",
524 help="Local tag for image")
525 parser
.add_argument("registry",
526 help="Docker registry")
528 def run(self
, args
, argv
):
530 dkr
.command(cmd
="pull", quiet
=args
.quiet
,
531 argv
=["%s/%s" % (args
.registry
, args
.tag
)])
532 dkr
.command(cmd
="tag", quiet
=args
.quiet
,
533 argv
=["%s/%s" % (args
.registry
, args
.tag
), args
.tag
])
536 class UpdateCommand(SubCommand
):
537 """ Update a docker image. Args: <tag> <actions>"""
540 def args(self
, parser
):
541 parser
.add_argument("tag",
543 parser
.add_argument("--executable",
544 help="Executable to copy")
545 parser
.add_argument("--add-current-user", "-u", dest
="user",
547 help="Add the current user to image's passwd")
549 def run(self
, args
, argv
):
550 # Create a temporary tarball with our whole build context and
551 # dockerfile for the update
552 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
553 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
555 # Create a Docker buildfile
557 df
.write(u
"FROM %s\n" % args
.tag
)
560 # Add the executable to the tarball, using the current
561 # configured binfmt_misc path. If we don't get a path then we
562 # only need the support libraries copied
563 ff
, enabled
= _check_binfmt_misc(args
.executable
)
566 print("binfmt_misc not enabled, update disabled")
570 tmp_tar
.add(args
.executable
, arcname
=ff
)
572 # Add any associated libraries
573 libs
= _get_so_libs(args
.executable
)
576 so_path
= os
.path
.dirname(l
)
577 name
= os
.path
.basename(l
)
578 real_l
= os
.path
.realpath(l
)
580 tmp_tar
.add(real_l
, arcname
="%s/%s" % (so_path
, name
))
581 except FileNotFoundError
:
582 print("Couldn't add %s/%s to archive" % (so_path
, name
))
585 df
.write(u
"ADD . /\n")
589 uname
= getpwuid(uid
).pw_name
591 df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
594 df_bytes
= BytesIO(bytes(df
.getvalue(), "UTF-8"))
596 df_tar
= TarInfo(name
="Dockerfile")
597 df_tar
.size
= df_bytes
.getbuffer().nbytes
598 tmp_tar
.addfile(df_tar
, fileobj
=df_bytes
)
602 # reset the file pointers
606 # Run the build with our tarball context
608 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
613 class CleanCommand(SubCommand
):
614 """Clean up docker instances"""
617 def run(self
, args
, argv
):
622 class ImagesCommand(SubCommand
):
623 """Run "docker images" command"""
626 def run(self
, args
, argv
):
627 return Docker().command("images", argv
, args
.quiet
)
630 class ProbeCommand(SubCommand
):
631 """Probe if we can run docker automatically"""
634 def run(self
, args
, argv
):
637 if docker
._command
[0] == "docker":
639 elif docker
._command
[0] == "sudo":
641 elif docker
._command
[0] == "podman":
649 class CcCommand(SubCommand
):
650 """Compile sources with cc in images"""
653 def args(self
, parser
):
654 parser
.add_argument("--image", "-i", required
=True,
655 help="The docker image in which to run cc")
656 parser
.add_argument("--cc", default
="cc",
657 help="The compiler executable to call")
658 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
659 help="""Extra paths to (ro) mount into container for
662 def run(self
, args
, argv
):
663 if argv
and argv
[0] == "--":
667 "-v", "%s:%s:rw" % (cwd
, cwd
)]
670 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
671 cmd
+= [args
.image
, args
.cc
]
673 return Docker().run(cmd
, False, quiet
=args
.quiet
,
677 class CheckCommand(SubCommand
):
678 """Check if we need to re-build a docker image out of a dockerfile.
679 Arguments: <tag> <dockerfile>"""
682 def args(self
, parser
):
683 parser
.add_argument("tag",
685 parser
.add_argument("dockerfile", default
=None,
686 help="Dockerfile name", nargs
='?')
687 parser
.add_argument("--checktype", choices
=["checksum", "age"],
688 default
="checksum", help="check type")
689 parser
.add_argument("--olderthan", default
=60, type=int,
690 help="number of minutes")
692 def run(self
, args
, argv
):
697 except subprocess
.CalledProcessError
:
698 print("Docker not set up")
701 info
= dkr
.inspect_tag(tag
)
703 print("Image does not exist")
706 if args
.checktype
== "checksum":
707 if not args
.dockerfile
:
708 print("Need a dockerfile for tag:%s" % (tag
))
711 dockerfile
= _read_dockerfile(args
.dockerfile
)
713 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
715 print("Image is up to date")
718 print("Image needs updating")
720 elif args
.checktype
== "age":
721 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
722 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
723 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
725 print ("Image created @ %s more than %d minutes old" %
726 (timestr
, args
.olderthan
))
730 print ("Image less than %d minutes old" % (args
.olderthan
))
737 parser
= argparse
.ArgumentParser(description
="A Docker helper",
738 usage
="%s <subcommand> ..." %
739 os
.path
.basename(sys
.argv
[0]))
740 parser
.add_argument("--engine", type=EngineEnum
.argparse
, choices
=list(EngineEnum
),
741 help="specify which container engine to use")
742 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
743 for cls
in SubCommand
.__subclasses
__():
745 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
746 cmd
.shared_args(subp
)
748 subp
.set_defaults(cmdobj
=cmd
)
749 args
, argv
= parser
.parse_known_args()
751 USE_ENGINE
= args
.engine
752 return args
.cmdobj
.run(args
, argv
)
755 if __name__
== "__main__":