master
py 657 lines 22 KB
Raw
1 #!/usr/bin/env python3
2 #
3 # Docker controlling module
4 #
5 # Copyright (c) 2016 Red Hat Inc.
6 #
7 # Authors:
8 # Fam Zheng <famz@redhat.com>
9 #
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.
13
14 import os
15 import sys
16 import subprocess
17 import json
18 import hashlib
19 import atexit
20 import uuid
21 import argparse
22 import enum
23 import tempfile
24 import re
25 import signal
26 import getpass
27 from tarfile import TarFile, TarInfo
28 from io import StringIO, BytesIO
29 from shutil import copy, rmtree
30 from datetime import datetime, timedelta
31
32
33 FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
34
35
36 DEVNULL = open(os.devnull, 'wb')
37
38 def _bytes_checksum(bytes):
39 """Calculate a digest string unique to the text content"""
40 return hashlib.sha1(bytes).hexdigest()
41
42 def _text_checksum(text):
43 """Calculate a digest string unique to the text content"""
44 return _bytes_checksum(text.encode('utf-8'))
45
46 def _read_dockerfile(path):
47 return open(path, 'rt', encoding='utf-8').read()
48
49 def _file_checksum(filename):
50 return _bytes_checksum(open(filename, 'rb').read())
51
52
53 def _guess_engine_command():
54 """ Guess a working engine command or raise exception if not found"""
55 commands = [["podman"],
56 ["podman-remote"],
57 ["podman", "--remote"],
58 ["docker"],
59 ["sudo", "-n", "docker"]]
60 for cmd in commands:
61 try:
62 # 'version' is not sufficient to prove a working binary
63 # for podman. 'info' is a stronger check that is more
64 # likely to correlate with ability to create containers,
65 # and required to detect the need for podman remote
66 if subprocess.call(cmd + ["info"],
67 stdout=DEVNULL, stderr=DEVNULL) == 0:
68 return cmd
69 except OSError:
70 pass
71 commands_txt = "\n".join([" " + " ".join(x) for x in commands])
72 raise Exception("Cannot find working engine command. Tried:\n%s" %
73 commands_txt)
74
75
76 def _copy_with_mkdir(src, root_dir, sub_path='.', name=None):
77 """Copy src into root_dir, creating sub_path as needed."""
78 dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
79 try:
80 os.makedirs(dest_dir)
81 except OSError:
82 # we can safely ignore already created directories
83 pass
84
85 dest_file = "%s/%s" % (dest_dir, name if name else os.path.basename(src))
86
87 try:
88 copy(src, dest_file)
89 except FileNotFoundError:
90 print("Couldn't copy %s to %s" % (src, dest_file))
91 pass
92
93
94 def _get_so_libs(executable):
95 """Return a list of libraries associated with an executable.
96
97 The paths may be symbolic links which would need to be resolved to
98 ensure the right data is copied."""
99
100 libs = []
101 ldd_re = re.compile(r"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)")
102 try:
103 ldd_output = subprocess.check_output(["ldd", executable]).decode('utf-8')
104 for line in ldd_output.split("\n"):
105 search = ldd_re.search(line)
106 if search:
107 try:
108 libs.append(search.group(1))
109 except IndexError:
110 pass
111 except subprocess.CalledProcessError:
112 print("%s had no associated libraries (static build?)" % (executable))
113
114 return libs
115
116
117 def _copy_binary_with_libs(src, bin_dest, dest_dir):
118 """Maybe copy a binary and all its dependent libraries.
119
120 If bin_dest isn't set we only copy the support libraries because
121 we don't need qemu in the docker path to run (due to persistent
122 mapping). Indeed users may get confused if we aren't running what
123 is in the image.
124
125 This does rely on the host file-system being fairly multi-arch
126 aware so the file don't clash with the guests layout.
127 """
128
129 if bin_dest:
130 _copy_with_mkdir(src, dest_dir, os.path.dirname(bin_dest))
131 else:
132 print("only copying support libraries for %s" % (src))
133
134 libs = _get_so_libs(src)
135 if libs:
136 for l in libs:
137 so_path = os.path.dirname(l)
138 name = os.path.basename(l)
139 real_l = os.path.realpath(l)
140 _copy_with_mkdir(real_l, dest_dir, so_path, name)
141
142
143 def _check_binfmt_misc(executable):
144 """Check binfmt_misc has entry for executable in the right place.
145
146 The details of setting up binfmt_misc are outside the scope of
147 this script but we should at least fail early with a useful
148 message if it won't work.
149
150 Returns the configured binfmt path and a valid flag. For
151 persistent configurations we will still want to copy and dependent
152 libraries.
153 """
154
155 binary = os.path.basename(executable)
156 binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
157
158 if not os.path.exists(binfmt_entry):
159 print ("No binfmt_misc entry for %s" % (binary))
160 return None, False
161
162 with open(binfmt_entry) as x: entry = x.read()
163
164 if re.search("flags:.*F.*\n", entry):
165 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
166 (binary))
167 return None, True
168
169 m = re.search(r"interpreter (\S+)\n", entry)
170 interp = m.group(1)
171 if interp and interp != executable:
172 print("binfmt_misc for %s does not point to %s, using %s" %
173 (binary, executable, interp))
174
175 return interp, True
176
177
178 def _read_qemu_dockerfile(img_name):
179 # special case for Debian linux-user images
180 if img_name.startswith("debian") and img_name.endswith("user"):
181 img_name = "debian-bootstrap"
182
183 df = os.path.join(os.path.dirname(__file__), "dockerfiles",
184 img_name + ".docker")
185 return _read_dockerfile(df)
186
187
188 def _dockerfile_verify_flat(df):
189 "Verify we do not include other qemu/ layers"
190 for l in df.splitlines():
191 if len(l.strip()) == 0 or l.startswith("#"):
192 continue
193 from_pref = "FROM qemu/"
194 if l.startswith(from_pref):
195 print("We no longer support multiple QEMU layers.")
196 print("Dockerfiles should be flat, ideally created by lcitool")
197 return False
198 return True
199
200
201 class Docker(object):
202 """ Running Docker commands """
203 def __init__(self, commandstr=None):
204 if commandstr is None:
205 self._command = _guess_engine_command()
206 else:
207 self._command = commandstr.split(" ")
208
209 if ("docker" in self._command and
210 "TRAVIS" not in os.environ and
211 "GITLAB_CI" not in os.environ):
212 os.environ["DOCKER_BUILDKIT"] = "1"
213 self._buildkit = True
214 else:
215 self._buildkit = False
216
217 self._instance = None
218 atexit.register(self._kill_instances)
219 signal.signal(signal.SIGTERM, self._kill_instances)
220 signal.signal(signal.SIGHUP, self._kill_instances)
221
222 def _do(self, cmd, quiet=True, **kwargs):
223 if quiet:
224 kwargs["stdout"] = DEVNULL
225 return subprocess.call(self._command + cmd, **kwargs)
226
227 def _do_check(self, cmd, quiet=True, **kwargs):
228 if quiet:
229 kwargs["stdout"] = DEVNULL
230 return subprocess.check_call(self._command + cmd, **kwargs)
231
232 def _do_kill_instances(self, only_known, only_active=True):
233 cmd = ["ps", "-q"]
234 if not only_active:
235 cmd.append("-a")
236
237 filter = "--filter=label=com.qemu.instance.uuid"
238 if only_known:
239 if self._instance:
240 filter += "=%s" % (self._instance)
241 else:
242 # no point trying to kill, we finished
243 return
244
245 print("filter=%s" % (filter))
246 cmd.append(filter)
247 for i in self._output(cmd).split():
248 self._do(["rm", "-f", i])
249
250 def clean(self):
251 self._do_kill_instances(False, False)
252 return 0
253
254 def _kill_instances(self, *args, **kwargs):
255 return self._do_kill_instances(True)
256
257 def _output(self, cmd, **kwargs):
258 try:
259 return subprocess.check_output(self._command + cmd,
260 stderr=subprocess.STDOUT,
261 encoding='utf-8',
262 **kwargs)
263 except TypeError:
264 # 'encoding' argument was added in 3.6+
265 return subprocess.check_output(self._command + cmd,
266 stderr=subprocess.STDOUT,
267 **kwargs).decode('utf-8')
268
269
270 def inspect_tag(self, tag):
271 try:
272 return self._output(["inspect", tag])
273 except subprocess.CalledProcessError:
274 return None
275
276 def get_image_creation_time(self, info):
277 return json.loads(info)[0]["Created"]
278
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", "")
283
284 def build_image(self, tag, docker_dir, dockerfile,
285 quiet=True, user=False, argv=None, registry=None,
286 extra_files_cksum=[]):
287 if argv is None:
288 argv = []
289
290 if not _dockerfile_verify_flat(dockerfile):
291 return -1
292
293 checksum = _text_checksum(dockerfile)
294
295 tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
296 encoding='utf-8',
297 dir=docker_dir, suffix=".docker")
298 tmp_df.write(dockerfile)
299
300 if user:
301 uid = os.getuid()
302 uname = getpass.getuser()
303 tmp_df.write("\n")
304 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
305 (uname, uid, uname))
306
307 tmp_df.write("\n")
308 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum))
309 for f, c in extra_files_cksum:
310 tmp_df.write("LABEL com.qemu.%s-checksum=%s\n" % (f, c))
311
312 tmp_df.flush()
313
314 build_args = ["build", "-t", tag, "-f", tmp_df.name]
315 if self._buildkit:
316 build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
317
318 if registry is not None:
319 pull_args = ["pull", "%s/%s" % (registry, tag)]
320 self._do(pull_args, quiet=quiet)
321 cache = "%s/%s" % (registry, tag)
322 build_args += ["--cache-from", cache]
323 build_args += argv
324 build_args += [docker_dir]
325
326 self._do_check(build_args,
327 quiet=quiet)
328
329 def update_image(self, tag, tarball, quiet=True):
330 "Update a tagged image using "
331
332 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
333
334 def image_matches_dockerfile(self, tag, dockerfile):
335 try:
336 checksum = self.get_image_dockerfile_checksum(tag)
337 except Exception:
338 return False
339 return checksum == _text_checksum(dockerfile)
340
341 def run(self, cmd, keep, quiet, as_user=False):
342 label = uuid.uuid4().hex
343 if not keep:
344 self._instance = label
345
346 if as_user:
347 uid = os.getuid()
348 cmd = [ "-u", str(uid) ] + cmd
349 # podman requires a bit more fiddling
350 if self._command[0] == "podman":
351 cmd.insert(0, '--userns=keep-id')
352
353 ret = self._do_check(["run", "--rm", "--label",
354 "com.qemu.instance.uuid=" + label] + cmd,
355 quiet=quiet)
356 if not keep:
357 self._instance = None
358 return ret
359
360 def command(self, cmd, argv, quiet):
361 return self._do([cmd] + argv, quiet=quiet)
362
363
364 class SubCommand(object):
365 """A SubCommand template base class"""
366 name = None # Subcommand name
367
368 def shared_args(self, parser):
369 parser.add_argument("--quiet", action="store_true",
370 help="Run quietly unless an error occurred")
371
372 def args(self, parser):
373 """Setup argument parser"""
374 pass
375
376 def run(self, args, argv):
377 """Run command.
378 args: parsed argument by argument parser.
379 argv: remaining arguments from sys.argv.
380 """
381 pass
382
383
384 class RunCommand(SubCommand):
385 """Invoke docker run and take care of cleaning up"""
386 name = "run"
387
388 def args(self, parser):
389 parser.add_argument("--keep", action="store_true",
390 help="Don't remove image when command completes")
391 parser.add_argument("--run-as-current-user", action="store_true",
392 help="Run container using the current user's uid")
393
394 def run(self, args, argv):
395 return Docker(args.command).run(argv, args.keep, quiet=args.quiet,
396 as_user=args.run_as_current_user)
397
398
399 class BuildCommand(SubCommand):
400 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
401 name = "build"
402
403 def args(self, parser):
404 parser.add_argument("--include-executable", "-e",
405 help="""Specify a binary that will be copied to the
406 container together with all its dependent
407 libraries""")
408 parser.add_argument("--skip-binfmt",
409 action="store_true",
410 help="""Skip binfmt entry check (used for testing)""")
411 parser.add_argument("--extra-files", nargs='*',
412 help="""Specify files that will be copied in the
413 Docker image, fulfilling the ADD directive from the
414 Dockerfile""")
415 parser.add_argument("--add-current-user", "-u", dest="user",
416 action="store_true",
417 help="Add the current user to image's passwd")
418 parser.add_argument("--registry", "-r",
419 help="cache from docker registry")
420 parser.add_argument("-t", dest="tag",
421 help="Image Tag")
422 parser.add_argument("-f", dest="dockerfile",
423 help="Dockerfile name")
424
425 def run(self, args, argv):
426 dockerfile = _read_dockerfile(args.dockerfile)
427 tag = args.tag
428
429 dkr = Docker(args.command)
430 if "--no-cache" not in argv and \
431 dkr.image_matches_dockerfile(tag, dockerfile):
432 if not args.quiet:
433 print("Image is up to date.")
434 else:
435 # Create a docker context directory for the build
436 docker_dir = tempfile.mkdtemp(prefix="docker_build")
437
438 # Validate binfmt_misc will work
439 if args.skip_binfmt:
440 qpath = args.include_executable
441 elif args.include_executable:
442 qpath, enabled = _check_binfmt_misc(args.include_executable)
443 if not enabled:
444 return 1
445
446 # Is there a .pre file to run in the build context?
447 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
448 if os.path.exists(docker_pre):
449 stdout = DEVNULL if args.quiet else None
450 rc = subprocess.call(os.path.realpath(docker_pre),
451 cwd=docker_dir, stdout=stdout)
452 if rc == 3:
453 print("Skip")
454 return 0
455 elif rc != 0:
456 print("%s exited with code %d" % (docker_pre, rc))
457 return 1
458
459 # Copy any extra files into the Docker context. These can be
460 # included by the use of the ADD directive in the Dockerfile.
461 cksum = []
462 if args.include_executable:
463 # FIXME: there is no checksum of this executable and the linked
464 # libraries, once the image built any change of this executable
465 # or any library won't trigger another build.
466 _copy_binary_with_libs(args.include_executable,
467 qpath, docker_dir)
468
469 for filename in args.extra_files or []:
470 _copy_with_mkdir(filename, docker_dir)
471 cksum += [(filename, _file_checksum(filename))]
472
473 argv += ["--build-arg=" + k.lower() + "=" + v
474 for k, v in os.environ.items()
475 if k.lower() in FILTERED_ENV_NAMES]
476 dkr.build_image(tag, docker_dir, dockerfile,
477 quiet=args.quiet, user=args.user,
478 argv=argv, registry=args.registry,
479 extra_files_cksum=cksum)
480
481 rmtree(docker_dir)
482
483 return 0
484
485 class FetchCommand(SubCommand):
486 """ Fetch a docker image from the registry. Args: <tag> <registry>"""
487 name = "fetch"
488
489 def args(self, parser):
490 parser.add_argument("tag",
491 help="Local tag for image")
492 parser.add_argument("registry",
493 help="Docker registry")
494
495 def run(self, args, argv):
496 dkr = Docker(args.command)
497 dkr.command(cmd="pull", quiet=args.quiet,
498 argv=["%s/%s" % (args.registry, args.tag)])
499 dkr.command(cmd="tag", quiet=args.quiet,
500 argv=["%s/%s" % (args.registry, args.tag), args.tag])
501
502
503 class UpdateCommand(SubCommand):
504 """ Update a docker image. Args: <tag> <actions>"""
505 name = "update"
506
507 def args(self, parser):
508 parser.add_argument("tag",
509 help="Image Tag")
510 parser.add_argument("--executable",
511 help="Executable to copy")
512 parser.add_argument("--add-current-user", "-u", dest="user",
513 action="store_true",
514 help="Add the current user to image's passwd")
515
516 def run(self, args, argv):
517 # Create a temporary tarball with our whole build context and
518 # dockerfile for the update
519 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
520 tmp_tar = TarFile(fileobj=tmp, mode='w')
521
522 # Create a Docker buildfile
523 df = StringIO()
524 df.write(u"FROM %s\n" % args.tag)
525
526 if args.executable:
527 # Add the executable to the tarball, using the current
528 # configured binfmt_misc path. If we don't get a path then we
529 # only need the support libraries copied
530 ff, enabled = _check_binfmt_misc(args.executable)
531
532 if not enabled:
533 print("binfmt_misc not enabled, update disabled")
534 return 1
535
536 if ff:
537 tmp_tar.add(args.executable, arcname=ff)
538
539 # Add any associated libraries
540 libs = _get_so_libs(args.executable)
541 if libs:
542 for l in libs:
543 so_path = os.path.dirname(l)
544 name = os.path.basename(l)
545 real_l = os.path.realpath(l)
546 try:
547 tmp_tar.add(real_l, arcname="%s/%s" % (so_path, name))
548 except FileNotFoundError:
549 print("Couldn't add %s/%s to archive" % (so_path, name))
550 pass
551
552 df.write(u"ADD . /\n")
553
554 if args.user:
555 uid = os.getuid()
556 uname = getpass.getuser()
557 df.write("\n")
558 df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
559 (uname, uid, uname))
560
561 df_bytes = BytesIO(bytes(df.getvalue(), "UTF-8"))
562
563 df_tar = TarInfo(name="Dockerfile")
564 df_tar.size = df_bytes.getbuffer().nbytes
565 tmp_tar.addfile(df_tar, fileobj=df_bytes)
566
567 tmp_tar.close()
568
569 # reset the file pointers
570 tmp.flush()
571 tmp.seek(0)
572
573 # Run the build with our tarball context
574 dkr = Docker(args.command)
575 dkr.update_image(args.tag, tmp, quiet=args.quiet)
576
577 return 0
578
579
580 class CleanCommand(SubCommand):
581 """Clean up docker instances"""
582 name = "clean"
583
584 def run(self, args, argv):
585 Docker(args.command).clean()
586 return 0
587
588
589 class ImagesCommand(SubCommand):
590 """Run "docker images" command"""
591 name = "images"
592
593 def run(self, args, argv):
594 return Docker(args.command).command("images", argv, args.quiet)
595
596
597 class ProbeCommand(SubCommand):
598 """Probe if we can run docker automatically"""
599 name = "probe"
600
601 def run(self, args, argv):
602 try:
603 docker = Docker(args.command)
604 print(" ".join(docker._command))
605 except Exception:
606 print("no")
607
608 return
609
610
611 class CcCommand(SubCommand):
612 """Compile sources with cc in images"""
613 name = "cc"
614
615 def args(self, parser):
616 parser.add_argument("--image", "-i", required=True,
617 help="The docker image in which to run cc")
618 parser.add_argument("--cc", default="cc",
619 help="The compiler executable to call")
620 parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
621 help="""Extra paths to (ro) mount into container for
622 reading sources""")
623
624 def run(self, args, argv):
625 if argv and argv[0] == "--":
626 argv = argv[1:]
627 cwd = os.getcwd()
628 cmd = ["-w", cwd,
629 "-v", "%s:%s:rw" % (cwd, cwd)]
630 if args.paths:
631 for p in args.paths:
632 cmd += ["-v", "%s:%s:ro,z" % (p, p)]
633 cmd += [args.image, args.cc]
634 cmd += argv
635 return Docker(args.command).run(cmd, False, quiet=args.quiet,
636 as_user=True)
637
638
639 def main():
640 parser = argparse.ArgumentParser(description="A Docker helper",
641 usage="%s <subcommand> ..." %
642 os.path.basename(sys.argv[0]))
643 parser.add_argument("--command",
644 help="specify which container engine command to use")
645 subparsers = parser.add_subparsers(title="subcommands", help=None)
646 for cls in SubCommand.__subclasses__():
647 cmd = cls()
648 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
649 cmd.shared_args(subp)
650 cmd.args(subp)
651 subp.set_defaults(cmdobj=cmd)
652 args, argv = parser.parse_known_args()
653 return args.cmdobj.run(args, argv)
654
655
656 if __name__ == "__main__":
657 sys.exit(main())