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.
27 from tarfile
import TarFile
, TarInfo
28 from io
import StringIO
, BytesIO
29 from shutil
import copy
, rmtree
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(r
"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_verify_flat(df
):
209 "Verify we do not include other qemu/ layers"
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 print("We no longer support multiple QEMU layers.")
216 print("Dockerfiles should be flat, ideally created by lcitool")
221 class Docker(object):
222 """ Running Docker commands """
224 self
._command
= _guess_engine_command()
226 if ("docker" in self
._command
and
227 "TRAVIS" not in os
.environ
and
228 "GITLAB_CI" not in os
.environ
):
229 os
.environ
["DOCKER_BUILDKIT"] = "1"
230 self
._buildkit
= True
232 self
._buildkit
= False
234 self
._instance
= None
235 atexit
.register(self
._kill
_instances
)
236 signal
.signal(signal
.SIGTERM
, self
._kill
_instances
)
237 signal
.signal(signal
.SIGHUP
, self
._kill
_instances
)
239 def _do(self
, cmd
, quiet
=True, **kwargs
):
241 kwargs
["stdout"] = DEVNULL
242 return subprocess
.call(self
._command
+ cmd
, **kwargs
)
244 def _do_check(self
, cmd
, quiet
=True, **kwargs
):
246 kwargs
["stdout"] = DEVNULL
247 return subprocess
.check_call(self
._command
+ cmd
, **kwargs
)
249 def _do_kill_instances(self
, only_known
, only_active
=True):
254 filter = "--filter=label=com.qemu.instance.uuid"
257 filter += "=%s" % (self
._instance
)
259 # no point trying to kill, we finished
262 print("filter=%s" % (filter))
264 for i
in self
._output
(cmd
).split():
265 self
._do
(["rm", "-f", i
])
268 self
._do
_kill
_instances
(False, False)
271 def _kill_instances(self
, *args
, **kwargs
):
272 return self
._do
_kill
_instances
(True)
274 def _output(self
, cmd
, **kwargs
):
276 return subprocess
.check_output(self
._command
+ cmd
,
277 stderr
=subprocess
.STDOUT
,
281 # 'encoding' argument was added in 3.6+
282 return subprocess
.check_output(self
._command
+ cmd
,
283 stderr
=subprocess
.STDOUT
,
284 **kwargs
).decode('utf-8')
287 def inspect_tag(self
, tag
):
289 return self
._output
(["inspect", tag
])
290 except subprocess
.CalledProcessError
:
293 def get_image_creation_time(self
, info
):
294 return json
.loads(info
)[0]["Created"]
296 def get_image_dockerfile_checksum(self
, tag
):
297 resp
= self
.inspect_tag(tag
)
298 labels
= json
.loads(resp
)[0]["Config"].get("Labels", {})
299 return labels
.get("com.qemu.dockerfile-checksum", "")
301 def build_image(self
, tag
, docker_dir
, dockerfile
,
302 quiet
=True, user
=False, argv
=None, registry
=None,
303 extra_files_cksum
=[]):
307 if not _dockerfile_verify_flat(dockerfile
):
310 checksum
= _text_checksum(dockerfile
)
312 tmp_df
= tempfile
.NamedTemporaryFile(mode
="w+t",
314 dir=docker_dir
, suffix
=".docker")
315 tmp_df
.write(dockerfile
)
319 uname
= getpass
.getuser()
321 tmp_df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
325 tmp_df
.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum
))
326 for f
, c
in extra_files_cksum
:
327 tmp_df
.write("LABEL com.qemu.%s-checksum=%s\n" % (f
, c
))
331 build_args
= ["build", "-t", tag
, "-f", tmp_df
.name
]
333 build_args
+= ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
335 if registry
is not None:
336 pull_args
= ["pull", "%s/%s" % (registry
, tag
)]
337 self
._do
(pull_args
, quiet
=quiet
)
338 cache
= "%s/%s" % (registry
, tag
)
339 build_args
+= ["--cache-from", cache
]
341 build_args
+= [docker_dir
]
343 self
._do
_check
(build_args
,
346 def update_image(self
, tag
, tarball
, quiet
=True):
347 "Update a tagged image using "
349 self
._do
_check
(["build", "-t", tag
, "-"], quiet
=quiet
, stdin
=tarball
)
351 def image_matches_dockerfile(self
, tag
, dockerfile
):
353 checksum
= self
.get_image_dockerfile_checksum(tag
)
356 return checksum
== _text_checksum(dockerfile
)
358 def run(self
, cmd
, keep
, quiet
, as_user
=False):
359 label
= uuid
.uuid4().hex
361 self
._instance
= label
365 cmd
= [ "-u", str(uid
) ] + cmd
366 # podman requires a bit more fiddling
367 if self
._command
[0] == "podman":
368 cmd
.insert(0, '--userns=keep-id')
370 ret
= self
._do
_check
(["run", "--rm", "--label",
371 "com.qemu.instance.uuid=" + label
] + cmd
,
374 self
._instance
= None
377 def command(self
, cmd
, argv
, quiet
):
378 return self
._do
([cmd
] + argv
, quiet
=quiet
)
381 class SubCommand(object):
382 """A SubCommand template base class"""
383 name
= None # Subcommand name
385 def shared_args(self
, parser
):
386 parser
.add_argument("--quiet", action
="store_true",
387 help="Run quietly unless an error occurred")
389 def args(self
, parser
):
390 """Setup argument parser"""
393 def run(self
, args
, argv
):
395 args: parsed argument by argument parser.
396 argv: remaining arguments from sys.argv.
401 class RunCommand(SubCommand
):
402 """Invoke docker run and take care of cleaning up"""
405 def args(self
, parser
):
406 parser
.add_argument("--keep", action
="store_true",
407 help="Don't remove image when command completes")
408 parser
.add_argument("--run-as-current-user", action
="store_true",
409 help="Run container using the current user's uid")
411 def run(self
, args
, argv
):
412 return Docker().run(argv
, args
.keep
, quiet
=args
.quiet
,
413 as_user
=args
.run_as_current_user
)
416 class BuildCommand(SubCommand
):
417 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
420 def args(self
, parser
):
421 parser
.add_argument("--include-executable", "-e",
422 help="""Specify a binary that will be copied to the
423 container together with all its dependent
425 parser
.add_argument("--skip-binfmt",
427 help="""Skip binfmt entry check (used for testing)""")
428 parser
.add_argument("--extra-files", nargs
='*',
429 help="""Specify files that will be copied in the
430 Docker image, fulfilling the ADD directive from the
432 parser
.add_argument("--add-current-user", "-u", dest
="user",
434 help="Add the current user to image's passwd")
435 parser
.add_argument("--registry", "-r",
436 help="cache from docker registry")
437 parser
.add_argument("-t", dest
="tag",
439 parser
.add_argument("-f", dest
="dockerfile",
440 help="Dockerfile name")
442 def run(self
, args
, argv
):
443 dockerfile
= _read_dockerfile(args
.dockerfile
)
447 if "--no-cache" not in argv
and \
448 dkr
.image_matches_dockerfile(tag
, dockerfile
):
450 print("Image is up to date.")
452 # Create a docker context directory for the build
453 docker_dir
= tempfile
.mkdtemp(prefix
="docker_build")
455 # Validate binfmt_misc will work
457 qpath
= args
.include_executable
458 elif args
.include_executable
:
459 qpath
, enabled
= _check_binfmt_misc(args
.include_executable
)
463 # Is there a .pre file to run in the build context?
464 docker_pre
= os
.path
.splitext(args
.dockerfile
)[0]+".pre"
465 if os
.path
.exists(docker_pre
):
466 stdout
= DEVNULL
if args
.quiet
else None
467 rc
= subprocess
.call(os
.path
.realpath(docker_pre
),
468 cwd
=docker_dir
, stdout
=stdout
)
473 print("%s exited with code %d" % (docker_pre
, rc
))
476 # Copy any extra files into the Docker context. These can be
477 # included by the use of the ADD directive in the Dockerfile.
479 if args
.include_executable
:
480 # FIXME: there is no checksum of this executable and the linked
481 # libraries, once the image built any change of this executable
482 # or any library won't trigger another build.
483 _copy_binary_with_libs(args
.include_executable
,
486 for filename
in args
.extra_files
or []:
487 _copy_with_mkdir(filename
, docker_dir
)
488 cksum
+= [(filename
, _file_checksum(filename
))]
490 argv
+= ["--build-arg=" + k
.lower() + "=" + v
491 for k
, v
in os
.environ
.items()
492 if k
.lower() in FILTERED_ENV_NAMES
]
493 dkr
.build_image(tag
, docker_dir
, dockerfile
,
494 quiet
=args
.quiet
, user
=args
.user
,
495 argv
=argv
, registry
=args
.registry
,
496 extra_files_cksum
=cksum
)
502 class FetchCommand(SubCommand
):
503 """ Fetch a docker image from the registry. Args: <tag> <registry>"""
506 def args(self
, parser
):
507 parser
.add_argument("tag",
508 help="Local tag for image")
509 parser
.add_argument("registry",
510 help="Docker registry")
512 def run(self
, args
, argv
):
514 dkr
.command(cmd
="pull", quiet
=args
.quiet
,
515 argv
=["%s/%s" % (args
.registry
, args
.tag
)])
516 dkr
.command(cmd
="tag", quiet
=args
.quiet
,
517 argv
=["%s/%s" % (args
.registry
, args
.tag
), args
.tag
])
520 class UpdateCommand(SubCommand
):
521 """ Update a docker image. Args: <tag> <actions>"""
524 def args(self
, parser
):
525 parser
.add_argument("tag",
527 parser
.add_argument("--executable",
528 help="Executable to copy")
529 parser
.add_argument("--add-current-user", "-u", dest
="user",
531 help="Add the current user to image's passwd")
533 def run(self
, args
, argv
):
534 # Create a temporary tarball with our whole build context and
535 # dockerfile for the update
536 tmp
= tempfile
.NamedTemporaryFile(suffix
="dckr.tar.gz")
537 tmp_tar
= TarFile(fileobj
=tmp
, mode
='w')
539 # Create a Docker buildfile
541 df
.write(u
"FROM %s\n" % args
.tag
)
544 # Add the executable to the tarball, using the current
545 # configured binfmt_misc path. If we don't get a path then we
546 # only need the support libraries copied
547 ff
, enabled
= _check_binfmt_misc(args
.executable
)
550 print("binfmt_misc not enabled, update disabled")
554 tmp_tar
.add(args
.executable
, arcname
=ff
)
556 # Add any associated libraries
557 libs
= _get_so_libs(args
.executable
)
560 so_path
= os
.path
.dirname(l
)
561 name
= os
.path
.basename(l
)
562 real_l
= os
.path
.realpath(l
)
564 tmp_tar
.add(real_l
, arcname
="%s/%s" % (so_path
, name
))
565 except FileNotFoundError
:
566 print("Couldn't add %s/%s to archive" % (so_path
, name
))
569 df
.write(u
"ADD . /\n")
573 uname
= getpass
.getuser()
575 df
.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
578 df_bytes
= BytesIO(bytes(df
.getvalue(), "UTF-8"))
580 df_tar
= TarInfo(name
="Dockerfile")
581 df_tar
.size
= df_bytes
.getbuffer().nbytes
582 tmp_tar
.addfile(df_tar
, fileobj
=df_bytes
)
586 # reset the file pointers
590 # Run the build with our tarball context
592 dkr
.update_image(args
.tag
, tmp
, quiet
=args
.quiet
)
597 class CleanCommand(SubCommand
):
598 """Clean up docker instances"""
601 def run(self
, args
, argv
):
606 class ImagesCommand(SubCommand
):
607 """Run "docker images" command"""
610 def run(self
, args
, argv
):
611 return Docker().command("images", argv
, args
.quiet
)
614 class ProbeCommand(SubCommand
):
615 """Probe if we can run docker automatically"""
618 def run(self
, args
, argv
):
621 if docker
._command
[0] == "docker":
623 elif docker
._command
[0] == "sudo":
625 elif docker
._command
[0] == "podman":
633 class CcCommand(SubCommand
):
634 """Compile sources with cc in images"""
637 def args(self
, parser
):
638 parser
.add_argument("--image", "-i", required
=True,
639 help="The docker image in which to run cc")
640 parser
.add_argument("--cc", default
="cc",
641 help="The compiler executable to call")
642 parser
.add_argument("--source-path", "-s", nargs
="*", dest
="paths",
643 help="""Extra paths to (ro) mount into container for
646 def run(self
, args
, argv
):
647 if argv
and argv
[0] == "--":
651 "-v", "%s:%s:rw" % (cwd
, cwd
)]
654 cmd
+= ["-v", "%s:%s:ro,z" % (p
, p
)]
655 cmd
+= [args
.image
, args
.cc
]
657 return Docker().run(cmd
, False, quiet
=args
.quiet
,
664 parser
= argparse
.ArgumentParser(description
="A Docker helper",
665 usage
="%s <subcommand> ..." %
666 os
.path
.basename(sys
.argv
[0]))
667 parser
.add_argument("--engine", type=EngineEnum
.argparse
, choices
=list(EngineEnum
),
668 help="specify which container engine to use")
669 subparsers
= parser
.add_subparsers(title
="subcommands", help=None)
670 for cls
in SubCommand
.__subclasses
__():
672 subp
= subparsers
.add_parser(cmd
.name
, help=cmd
.__doc
__)
673 cmd
.shared_args(subp
)
675 subp
.set_defaults(cmdobj
=cmd
)
676 args
, argv
= parser
.parse_known_args()
678 USE_ENGINE
= args
.engine
679 return args
.cmdobj
.run(args
, argv
)
682 if __name__
== "__main__":