docker.py 22 KB

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