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
232 "TRAVIS" not in os
.environ
and
233 "GITLAB_CI" not in os
.environ
):
234 os
.environ
["DOCKER_BUILDKIT"] = "1"
235 self
._buildkit
= True
237 self
._buildkit
= False
239 self
._instance
= None
240 atexit
.register(self
._kill
_instances
)
241 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
242 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
244 def _do(self
, cmd
, quiet
=True, **kwargs
):
246 kwargs
["stdout"] = DEVNULL
247 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
249 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
251 kwargs
["stdout"] = DEVNULL
252 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
254 def _do_kill_instances(self
, only_known
, only_active
=True):
259 filter = "--filter=label=com.qemu.instance.uuid"
262 filter += "=%s" % (self
._instance
)
264 # no point trying to kill, we finished
267 print("filter=%s" % (filter))
269 for i
in self
._output
(cmd
).split():
270 self
._do
(["rm", "-f", i
])
273 self
._do
_kill
_instances
(False, False)
276 def _kill_instances(self
, *args
, **kwargs
):
277 return self
._do
_kill
_instances
(True)
279 def _output(self
, cmd
, **kwargs
):
281 return subprocess
.check_output(self
._command
+ cmd
,
282 stderr
=subprocess
.STDOUT
,
286 # 'encoding' argument was added in 3.6+
287 return subprocess
.check_output(self
._command
+ cmd
,
288 stderr
=subprocess
.STDOUT
,
289 **kwargs
).decode('utf-8')
292 def inspect_tag(self
, tag
):
294 return self
._output
(["inspect", tag
])
295 except subprocess
.CalledProcessError
:
298 def get_image_creation_time(self
, info
):
299 return json
.loads(info
)[0]["Created"]
301 def get_image_dockerfile_checksum(self
, tag
):
302 resp
= self
.inspect_tag(tag
)
303 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
304 return labels
.get("com.qemu.dockerfile-checksum", "")
306 def build_image(self
, tag
, docker_dir
, dockerfile
,
307 quiet
=True, user
=False, argv
=None, registry
=None,
308 extra_files_cksum
=[]):
312 # pre-calculate the docker checksum before any
313 # substitutions we make for caching
314 checksum
= _text_checksum(_dockerfile_preprocess(dockerfile
))
316 if registry
is not None:
317 sources
= re
.findall("FROM qemu\/(.*)", dockerfile
)
318 # Fetch any cache layers we can, may fail
320 pull_args
= ["pull", "%s/qemu/%s" % (registry
, s
)]
321 if self
._do
(pull_args
, quiet
=quiet
) != 0:
325 if registry
is not None:
326 dockerfile
= dockerfile
.replace("FROM qemu/",
330 tmp_df
= tempfile
.NamedTemporaryFile(mode
="w+t",
332 dir=docker_dir
, suffix
=".docker")
333 tmp_df
.write(dockerfile
)
337 uname
= getpwuid(uid
).pw_name
339 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
343 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum
))
344 for f
, c
in extra_files_cksum
:
345 tmp_df
.write("LABEL com.qemu.%s-checksum=%s\n" % (f
, c
))
349 build_args
= ["build", "-t", tag
, "-f", tmp_df
.name
]
351 build_args
+= ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
353 if registry
is not None:
354 pull_args
= ["pull", "%s/%s" % (registry
, tag
)]
355 self
._do
(pull_args
, quiet
=quiet
)
356 cache
= "%s/%s" % (registry
, tag
)
357 build_args
+= ["--cache-from", cache
]
359 build_args
+= [docker_dir
]
361 self
._do
_check
(build_args
,
364 def update_image(self
, tag
, tarball
, quiet
=True):
365 "Update a tagged image using "
367 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
369 def image_matches_dockerfile(self
, tag
, dockerfile
):
371 checksum
= self
.get_image_dockerfile_checksum(tag
)
374 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
376 def run(self
, cmd
, keep
, quiet
, as_user
=False):
377 label
= uuid
.uuid4().hex
379 self
._instance
= label
383 cmd
= [ "-u", str(uid
) ] + cmd
384 # podman requires a bit more fiddling
385 if self
._command
[0] == "podman":
386 cmd
.insert(0, '--userns=keep-id')
388 ret
= self
._do
_check
(["run", "--rm", "--label",
389 "com.qemu.instance.uuid=" + label
] + cmd
,
392 self
._instance
= None
395 def command(self
, cmd
, argv
, quiet
):
396 return self
._do
([cmd
] + argv
, quiet
=quiet
)
399 class SubCommand(object):
400 """A SubCommand template base class"""
401 name
= None # Subcommand name
403 def shared_args(self
, parser
):
404 parser
.add_argument("--quiet", action
="store_true",
405 help="Run quietly unless an error occurred")
407 def args(self
, parser
):
408 """Setup argument parser"""
411 def run(self
, args
, argv
):
413 args: parsed argument by argument parser.
414 argv: remaining arguments from sys.argv.
419 class RunCommand(SubCommand
):
420 """Invoke docker run and take care of cleaning up"""
423 def args(self
, parser
):
424 parser
.add_argument("--keep", action
="store_true",
425 help="Don't remove image when command completes")
426 parser
.add_argument("--run-as-current-user", action
="store_true",
427 help="Run container using the current user's uid")
429 def run(self
, args
, argv
):
430 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
,
431 as_user
=args
.run_as_current_user
)
434 class BuildCommand(SubCommand
):
435 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
438 def args(self
, parser
):
439 parser
.add_argument("--include-executable", "-e",
440 help="""Specify a binary that will be copied to the
441 container together with all its dependent
443 parser
.add_argument("--skip-binfmt",
445 help="""Skip binfmt entry check (used for testing)""")
446 parser
.add_argument("--extra-files", nargs
='*',
447 help="""Specify files that will be copied in the
448 Docker image, fulfilling the ADD directive from the
450 parser
.add_argument("--add-current-user", "-u", dest
="user",
452 help="Add the current user to image's passwd")
453 parser
.add_argument("--registry", "-r",
454 help="cache from docker registry")
455 parser
.add_argument("-t", dest
="tag",
457 parser
.add_argument("-f", dest
="dockerfile",
458 help="Dockerfile name")
460 def run(self
, args
, argv
):
461 dockerfile
= _read_dockerfile(args
.dockerfile
)
465 if "--no-cache" not in argv
and \
466 dkr
.image_matches_dockerfile(tag
, dockerfile
):
468 print("Image is up to date.")
470 # Create a docker context directory for the build
471 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
473 # Validate binfmt_misc will work
475 qpath
= args
.include_executable
476 elif args
.include_executable
:
477 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
481 # Is there a .pre file to run in the build context?
482 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
483 if os
.path
.exists(docker_pre
):
484 stdout
= DEVNULL
if args
.quiet
else None
485 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
486 cwd
=docker_dir
, stdout
=stdout
)
491 print("%s exited with code %d" % (docker_pre
, rc
))
494 # Copy any extra files into the Docker context. These can be
495 # included by the use of the ADD directive in the Dockerfile.
497 if args
.include_executable
:
498 # FIXME: there is no checksum of this executable and the linked
499 # libraries, once the image built any change of this executable
500 # or any library won't trigger another build.
501 _copy_binary_with_libs(args
.include_executable
,
504 for filename
in args
.extra_files
or []:
505 _copy_with_mkdir(filename
, docker_dir
)
506 cksum
+= [(filename
, _file_checksum(filename
))]
508 argv
+= ["--build-arg=" + k
.lower() + "=" + v
509 for k
, v
in os
.environ
.items()
510 if k
.lower() in FILTERED_ENV_NAMES
]
511 dkr
.build_image(tag
, docker_dir
, dockerfile
,
512 quiet
=args
.quiet
, user
=args
.user
,
513 argv
=argv
, registry
=args
.registry
,
514 extra_files_cksum
=cksum
)
520 class FetchCommand(SubCommand
):
521 """ Fetch a docker image from the registry. Args: <tag> <registry>"""
524 def args(self
, parser
):
525 parser
.add_argument("tag",
526 help="Local tag for image")
527 parser
.add_argument("registry",
528 help="Docker registry")
530 def run(self
, args
, argv
):
532 dkr
.command(cmd
="pull", quiet
=args
.quiet
,
533 argv
=["%s/%s" % (args
.registry
, args
.tag
)])
534 dkr
.command(cmd
="tag", quiet
=args
.quiet
,
535 argv
=["%s/%s" % (args
.registry
, args
.tag
), args
.tag
])
538 class UpdateCommand(SubCommand
):
539 """ Update a docker image. Args: <tag> <actions>"""
542 def args(self
, parser
):
543 parser
.add_argument("tag",
545 parser
.add_argument("--executable",
546 help="Executable to copy")
547 parser
.add_argument("--add-current-user", "-u", dest
="user",
549 help="Add the current user to image's passwd")
551 def run(self
, args
, argv
):
552 # Create a temporary tarball with our whole build context and
553 # dockerfile for the update
554 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
555 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
557 # Create a Docker buildfile
559 df
.write(u
"FROM %s\n" % args
.tag
)
562 # Add the executable to the tarball, using the current
563 # configured binfmt_misc path. If we don't get a path then we
564 # only need the support libraries copied
565 ff
, enabled
= _check_binfmt_misc(args
.executable
)
568 print("binfmt_misc not enabled, update disabled")
572 tmp_tar
.add(args
.executable
, arcname
=ff
)
574 # Add any associated libraries
575 libs
= _get_so_libs(args
.executable
)
578 so_path
= os
.path
.dirname(l
)
579 name
= os
.path
.basename(l
)
580 real_l
= os
.path
.realpath(l
)
582 tmp_tar
.add(real_l
, arcname
="%s/%s" % (so_path
, name
))
583 except FileNotFoundError
:
584 print("Couldn't add %s/%s to archive" % (so_path
, name
))
587 df
.write(u
"ADD . /\n")
591 uname
= getpwuid(uid
).pw_name
593 df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
596 df_bytes
= BytesIO(bytes(df
.getvalue(), "UTF-8"))
598 df_tar
= TarInfo(name
="Dockerfile")
599 df_tar
.size
= df_bytes
.getbuffer().nbytes
600 tmp_tar
.addfile(df_tar
, fileobj
=df_bytes
)
604 # reset the file pointers
608 # Run the build with our tarball context
610 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
615 class CleanCommand(SubCommand
):
616 """Clean up docker instances"""
619 def run(self
, args
, argv
):
624 class ImagesCommand(SubCommand
):
625 """Run "docker images" command"""
628 def run(self
, args
, argv
):
629 return Docker().command("images", argv
, args
.quiet
)
632 class ProbeCommand(SubCommand
):
633 """Probe if we can run docker automatically"""
636 def run(self
, args
, argv
):
639 if docker
._command
[0] == "docker":
641 elif docker
._command
[0] == "sudo":
643 elif docker
._command
[0] == "podman":
651 class CcCommand(SubCommand
):
652 """Compile sources with cc in images"""
655 def args(self
, parser
):
656 parser
.add_argument("--image", "-i", required
=True,
657 help="The docker image in which to run cc")
658 parser
.add_argument("--cc", default
="cc",
659 help="The compiler executable to call")
660 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
661 help="""Extra paths to (ro) mount into container for
664 def run(self
, args
, argv
):
665 if argv
and argv
[0] == "--":
669 "-v", "%s:%s:rw" % (cwd
, cwd
)]
672 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
673 cmd
+= [args
.image
, args
.cc
]
675 return Docker().run(cmd
, False, quiet
=args
.quiet
,
679 class CheckCommand(SubCommand
):
680 """Check if we need to re-build a docker image out of a dockerfile.
681 Arguments: <tag> <dockerfile>"""
684 def args(self
, parser
):
685 parser
.add_argument("tag",
687 parser
.add_argument("dockerfile", default
=None,
688 help="Dockerfile name", nargs
='?')
689 parser
.add_argument("--checktype", choices
=["checksum", "age"],
690 default
="checksum", help="check type")
691 parser
.add_argument("--olderthan", default
=60, type=int,
692 help="number of minutes")
694 def run(self
, args
, argv
):
699 except subprocess
.CalledProcessError
:
700 print("Docker not set up")
703 info
= dkr
.inspect_tag(tag
)
705 print("Image does not exist")
708 if args
.checktype
== "checksum":
709 if not args
.dockerfile
:
710 print("Need a dockerfile for tag:%s" % (tag
))
713 dockerfile
= _read_dockerfile(args
.dockerfile
)
715 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
717 print("Image is up to date")
720 print("Image needs updating")
722 elif args
.checktype
== "age":
723 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
724 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
725 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
727 print ("Image created @ %s more than %d minutes old" %
728 (timestr
, args
.olderthan
))
732 print ("Image less than %d minutes old" % (args
.olderthan
))
739 parser
= argparse
.ArgumentParser(description
="A Docker helper",
740 usage
="%s <subcommand> ..." %
741 os
.path
.basename(sys
.argv
[0]))
742 parser
.add_argument("--engine", type=EngineEnum
.argparse
, choices
=list(EngineEnum
),
743 help="specify which container engine to use")
744 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
745 for cls
in SubCommand
.__subclasses
__():
747 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
748 cmd
.shared_args(subp
)
750 subp
.set_defaults(cmdobj
=cmd
)
751 args
, argv
= parser
.parse_known_args()
753 USE_ENGINE
= args
.engine
754 return args
.cmdobj
.run(args
, argv
)
757 if __name__
== "__main__":