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()
225 if "docker" in self
._command
and "TRAVIS" not in os
.environ
:
226 os
.environ
["DOCKER_BUILDKIT"] = "1"
227 self
._buildkit
= True
229 self
._buildkit
= False
231 self
._instance
= None
232 atexit
.register(self
._kill
_instances
)
233 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
234 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
236 def _do(self
, cmd
, quiet
=True, **kwargs
):
238 kwargs
["stdout"] = DEVNULL
239 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
241 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
243 kwargs
["stdout"] = DEVNULL
244 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
246 def _do_kill_instances(self
, only_known
, only_active
=True):
251 filter = "--filter=label=com.qemu.instance.uuid"
254 filter += "=%s" % (self
._instance
)
256 # no point trying to kill, we finished
259 print("filter=%s" % (filter))
261 for i
in self
._output
(cmd
).split():
262 self
._do
(["rm", "-f", i
])
265 self
._do
_kill
_instances
(False, False)
268 def _kill_instances(self
, *args
, **kwargs
):
269 return self
._do
_kill
_instances
(True)
271 def _output(self
, cmd
, **kwargs
):
273 return subprocess
.check_output(self
._command
+ cmd
,
274 stderr
=subprocess
.STDOUT
,
278 # 'encoding' argument was added in 3.6+
279 return subprocess
.check_output(self
._command
+ cmd
,
280 stderr
=subprocess
.STDOUT
,
281 **kwargs
).decode('utf-8')
284 def inspect_tag(self
, tag
):
286 return self
._output
(["inspect", tag
])
287 except subprocess
.CalledProcessError
:
290 def get_image_creation_time(self
, info
):
291 return json
.loads(info
)[0]["Created"]
293 def get_image_dockerfile_checksum(self
, tag
):
294 resp
= self
.inspect_tag(tag
)
295 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
296 return labels
.get("com.qemu.dockerfile-checksum", "")
298 def build_image(self
, tag
, docker_dir
, dockerfile
,
299 quiet
=True, user
=False, argv
=None, registry
=None,
300 extra_files_cksum
=[]):
304 # pre-calculate the docker checksum before any
305 # substitutions we make for caching
306 checksum
= _text_checksum(_dockerfile_preprocess(dockerfile
))
308 if registry
is not None:
309 sources
= re
.findall("FROM qemu\/(.*)", dockerfile
)
310 # Fetch any cache layers we can, may fail
312 pull_args
= ["pull", "%s/qemu/%s" % (registry
, s
)]
313 if self
._do
(pull_args
, quiet
=quiet
) != 0:
317 if registry
is not None:
318 dockerfile
= dockerfile
.replace("FROM qemu/",
322 tmp_df
= tempfile
.NamedTemporaryFile(mode
="w+t",
324 dir=docker_dir
, suffix
=".docker")
325 tmp_df
.write(dockerfile
)
329 uname
= getpwuid(uid
).pw_name
331 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
335 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s" % (checksum
))
336 for f
, c
in extra_files_cksum
:
337 tmp_df
.write("LABEL com.qemu.%s-checksum=%s" % (f
, c
))
341 build_args
= ["build", "-t", tag
, "-f", tmp_df
.name
]
343 build_args
+= ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
345 if registry
is not None:
346 pull_args
= ["pull", "%s/%s" % (registry
, tag
)]
347 self
._do
(pull_args
, quiet
=quiet
)
348 cache
= "%s/%s" % (registry
, tag
)
349 build_args
+= ["--cache-from", cache
]
351 build_args
+= [docker_dir
]
353 self
._do
_check
(build_args
,
356 def update_image(self
, tag
, tarball
, quiet
=True):
357 "Update a tagged image using "
359 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
361 def image_matches_dockerfile(self
, tag
, dockerfile
):
363 checksum
= self
.get_image_dockerfile_checksum(tag
)
366 return checksum
== _text_checksum(_dockerfile_preprocess(dockerfile
))
368 def run(self
, cmd
, keep
, quiet
, as_user
=False):
369 label
= uuid
.uuid4().hex
371 self
._instance
= label
375 cmd
= [ "-u", str(uid
) ] + cmd
376 # podman requires a bit more fiddling
377 if self
._command
[0] == "podman":
378 cmd
.insert(0, '--userns=keep-id')
380 ret
= self
._do
_check
(["run", "--label",
381 "com.qemu.instance.uuid=" + label
] + cmd
,
384 self
._instance
= None
387 def command(self
, cmd
, argv
, quiet
):
388 return self
._do
([cmd
] + argv
, quiet
=quiet
)
391 class SubCommand(object):
392 """A SubCommand template base class"""
393 name
= None # Subcommand name
395 def shared_args(self
, parser
):
396 parser
.add_argument("--quiet", action
="store_true",
397 help="Run quietly unless an error occurred")
399 def args(self
, parser
):
400 """Setup argument parser"""
403 def run(self
, args
, argv
):
405 args: parsed argument by argument parser.
406 argv: remaining arguments from sys.argv.
411 class RunCommand(SubCommand
):
412 """Invoke docker run and take care of cleaning up"""
415 def args(self
, parser
):
416 parser
.add_argument("--keep", action
="store_true",
417 help="Don't remove image when command completes")
418 parser
.add_argument("--run-as-current-user", action
="store_true",
419 help="Run container using the current user's uid")
421 def run(self
, args
, argv
):
422 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
,
423 as_user
=args
.run_as_current_user
)
426 class BuildCommand(SubCommand
):
427 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
430 def args(self
, parser
):
431 parser
.add_argument("--include-executable", "-e",
432 help="""Specify a binary that will be copied to the
433 container together with all its dependent
435 parser
.add_argument("--extra-files", nargs
='*',
436 help="""Specify files that will be copied in the
437 Docker image, fulfilling the ADD directive from the
439 parser
.add_argument("--add-current-user", "-u", dest
="user",
441 help="Add the current user to image's passwd")
442 parser
.add_argument("--registry", "-r",
443 help="cache from docker registry")
444 parser
.add_argument("-t", dest
="tag",
446 parser
.add_argument("-f", dest
="dockerfile",
447 help="Dockerfile name")
449 def run(self
, args
, argv
):
450 dockerfile
= _read_dockerfile(args
.dockerfile
)
454 if "--no-cache" not in argv
and \
455 dkr
.image_matches_dockerfile(tag
, dockerfile
):
457 print("Image is up to date.")
459 # Create a docker context directory for the build
460 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
462 # Validate binfmt_misc will work
463 if args
.include_executable
:
464 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
468 # Is there a .pre file to run in the build context?
469 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
470 if os
.path
.exists(docker_pre
):
471 stdout
= DEVNULL
if args
.quiet
else None
472 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
473 cwd
=docker_dir
, stdout
=stdout
)
478 print("%s exited with code %d" % (docker_pre
, rc
))
481 # Copy any extra files into the Docker context. These can be
482 # included by the use of the ADD directive in the Dockerfile.
484 if args
.include_executable
:
485 # FIXME: there is no checksum of this executable and the linked
486 # libraries, once the image built any change of this executable
487 # or any library won't trigger another build.
488 _copy_binary_with_libs(args
.include_executable
,
491 for filename
in args
.extra_files
or []:
492 _copy_with_mkdir(filename
, docker_dir
)
493 cksum
+= [(filename
, _file_checksum(filename
))]
495 argv
+= ["--build-arg=" + k
.lower() + "=" + v
496 for k
, v
in os
.environ
.items()
497 if k
.lower() in FILTERED_ENV_NAMES
]
498 dkr
.build_image(tag
, docker_dir
, dockerfile
,
499 quiet
=args
.quiet
, user
=args
.user
,
500 argv
=argv
, registry
=args
.registry
,
501 extra_files_cksum
=cksum
)
508 class UpdateCommand(SubCommand
):
509 """ Update a docker image with new executables. Args: <tag> <executable>"""
512 def args(self
, parser
):
513 parser
.add_argument("tag",
515 parser
.add_argument("executable",
516 help="Executable to copy")
518 def run(self
, args
, argv
):
519 # Create a temporary tarball with our whole build context and
520 # dockerfile for the update
521 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
522 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
524 # Add the executable to the tarball, using the current
525 # configured binfmt_misc path. If we don't get a path then we
526 # only need the support libraries copied
527 ff
, enabled
= _check_binfmt_misc(args
.executable
)
530 print("binfmt_misc not enabled, update disabled")
534 tmp_tar
.add(args
.executable
, arcname
=ff
)
536 # Add any associated libraries
537 libs
= _get_so_libs(args
.executable
)
540 tmp_tar
.add(os
.path
.realpath(l
), arcname
=l
)
542 # Create a Docker buildfile
544 df
.write("FROM %s\n" % args
.tag
)
545 df
.write("ADD . /\n")
548 df_tar
= TarInfo(name
="Dockerfile")
549 df_tar
.size
= len(df
.buf
)
550 tmp_tar
.addfile(df_tar
, fileobj
=df
)
554 # reset the file pointers
558 # Run the build with our tarball context
560 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
565 class CleanCommand(SubCommand
):
566 """Clean up docker instances"""
569 def run(self
, args
, argv
):
574 class ImagesCommand(SubCommand
):
575 """Run "docker images" command"""
578 def run(self
, args
, argv
):
579 return Docker().command("images", argv
, args
.quiet
)
582 class ProbeCommand(SubCommand
):
583 """Probe if we can run docker automatically"""
586 def run(self
, args
, argv
):
589 if docker
._command
[0] == "docker":
591 elif docker
._command
[0] == "sudo":
593 elif docker
._command
[0] == "podman":
601 class CcCommand(SubCommand
):
602 """Compile sources with cc in images"""
605 def args(self
, parser
):
606 parser
.add_argument("--image", "-i", required
=True,
607 help="The docker image in which to run cc")
608 parser
.add_argument("--cc", default
="cc",
609 help="The compiler executable to call")
610 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
611 help="""Extra paths to (ro) mount into container for
614 def run(self
, args
, argv
):
615 if argv
and argv
[0] == "--":
618 cmd
= ["--rm", "-w", cwd
,
619 "-v", "%s:%s:rw" % (cwd
, cwd
)]
622 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
623 cmd
+= [args
.image
, args
.cc
]
625 return Docker().run(cmd
, False, quiet
=args
.quiet
,
629 class CheckCommand(SubCommand
):
630 """Check if we need to re-build a docker image out of a dockerfile.
631 Arguments: <tag> <dockerfile>"""
634 def args(self
, parser
):
635 parser
.add_argument("tag",
637 parser
.add_argument("dockerfile", default
=None,
638 help="Dockerfile name", nargs
='?')
639 parser
.add_argument("--checktype", choices
=["checksum", "age"],
640 default
="checksum", help="check type")
641 parser
.add_argument("--olderthan", default
=60, type=int,
642 help="number of minutes")
644 def run(self
, args
, argv
):
649 except subprocess
.CalledProcessError
:
650 print("Docker not set up")
653 info
= dkr
.inspect_tag(tag
)
655 print("Image does not exist")
658 if args
.checktype
== "checksum":
659 if not args
.dockerfile
:
660 print("Need a dockerfile for tag:%s" % (tag
))
663 dockerfile
= _read_dockerfile(args
.dockerfile
)
665 if dkr
.image_matches_dockerfile(tag
, dockerfile
):
667 print("Image is up to date")
670 print("Image needs updating")
672 elif args
.checktype
== "age":
673 timestr
= dkr
.get_image_creation_time(info
).split(".")[0]
674 created
= datetime
.strptime(timestr
, "%Y-%m-%dT%H:%M:%S")
675 past
= datetime
.now() - timedelta(minutes
=args
.olderthan
)
677 print ("Image created @ %s more than %d minutes old" %
678 (timestr
, args
.olderthan
))
682 print ("Image less than %d minutes old" % (args
.olderthan
))
689 parser
= argparse
.ArgumentParser(description
="A Docker helper",
690 usage
="%s <subcommand> ..." %
691 os
.path
.basename(sys
.argv
[0]))
692 parser
.add_argument("--engine", type=EngineEnum
.argparse
, choices
=list(EngineEnum
),
693 help="specify which container engine to use")
694 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
695 for cls
in SubCommand
.__subclasses
__():
697 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
698 cmd
.shared_args(subp
)
700 subp
.set_defaults(cmdobj
=cmd
)
701 args
, argv
= parser
.parse_known_args()
703 USE_ENGINE
= args
.engine
704 return args
.cmdobj
.run(args
, argv
)
707 if __name__
== "__main__":