baiji 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. #!/usr/bin/env python
  2. # Copyright (c) 2018-2019, Grunchweather Associates
  3. #
  4. # Permission to use, copy, modify, and/or distribute this software for any
  5. # purpose with or without fee is hereby granted, provided that the above
  6. # copyright notice and this permission notice appear in all copies.
  7. #
  8. # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
  9. # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  10. # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
  11. # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
  12. # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
  13. # OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  14. # PERFORMANCE OF THIS SOFTWARE.
  15. import debian.changelog
  16. import debian.deb822
  17. import subprocess
  18. import textwrap
  19. import argparse
  20. import tempfile
  21. import tarfile
  22. import hashlib
  23. import shutil
  24. import sys
  25. import os
  26. # Python decorator voodoo to simplify argparse subparser setup.
  27. def arg(*a, **k):
  28. return a, k
  29. def cmd(*args):
  30. def wrapper(func):
  31. def setup(subp):
  32. for a, k in args:
  33. subp.add_argument(*a, **k)
  34. subp.set_defaults(func = func)
  35. func._setup_parser = setup
  36. return func
  37. return wrapper
  38. # Context manager for temporary directories.
  39. class tempdir(object):
  40. def __enter__(self):
  41. self.dn = tempfile.mkdtemp()
  42. return self.dn
  43. def __exit__(self, *oops):
  44. shutil.rmtree(self.dn)
  45. # Docker process, mostly a context manager around subprocess.Popen.
  46. # We could use the native Python Docker interface, but the packaged
  47. # Debian version of that has a wildly different API than the version
  48. # on GitHub.
  49. class Docker(subprocess.Popen):
  50. class DockerError(Exception):
  51. "Docker returned failure."
  52. def __init__(self, *args, **kwargs):
  53. super(Docker, self).__init__(("docker",) + args, **kwargs)
  54. def __enter__(self):
  55. return self
  56. def __exit__(self, *oops):
  57. if any(oops):
  58. return
  59. if self.stdin:
  60. self.stdin.close()
  61. status = self.wait()
  62. if status:
  63. raise self.DockerError()
  64. # Filter which acts like fakeroot for tarfile.TarFile.add()
  65. def fakeroot_filter(info):
  66. info.uname = info.gname = "root"
  67. info.uid = info.gid = 0
  68. return info
  69. # Commands
  70. @cmd(arg("--dist", default = "jessie",
  71. help = "distribution for base docker image"),
  72. arg("--tag", default = "baiji:jessie",
  73. help = "tag to use for constructed base docker image"),
  74. )
  75. def create(args):
  76. """
  77. Construct a base Docker image.
  78. This is mostly just the output of debootstrap, with a bit of extra
  79. setup to include git, build-essentials, and fakeroot.
  80. """
  81. with tempdir() as dn:
  82. subprocess.check_call(("fakeroot", "/usr/sbin/debootstrap",
  83. "--foreign", "--variant=buildd", args.dist, dn))
  84. with Docker("import", "-", args.tag, stdin = subprocess.PIPE) as docker:
  85. with tarfile.open(mode = "w|", fileobj = docker.stdin) as tar:
  86. tar.add(dn, ".", filter = fakeroot_filter)
  87. with Docker("build", "-t", args.tag, "-", stdin = subprocess.PIPE) as docker:
  88. docker.communicate(textwrap.dedent('''\
  89. FROM {args.tag}
  90. RUN sed -i '/mount -t proc /d; /mount -t sysfs /d' /debootstrap/functions && \\
  91. /debootstrap/debootstrap --second-stage
  92. RUN apt-get update && \\
  93. apt-get install -y --no-install-recommends build-essential fakeroot git apt-utils
  94. RUN useradd -U -m -d /build baiji
  95. WORKDIR /build
  96. '''.format(args = args)))
  97. @cmd(arg("--tag", default = "baiji:jessie",
  98. help = "tag of base docker image to update"),
  99. )
  100. def update(args):
  101. """
  102. Update a base Docker image.
  103. """
  104. with Docker("build", "-t", args.tag, "-", stdin = subprocess.PIPE) as docker:
  105. docker.communicate(textwrap.dedent('''\
  106. FROM {args.tag}
  107. RUN apt-get update && \\
  108. apt-get upgrade -y --with-new-pkgs --no-install-recommends && \\
  109. apt-get autoremove && \\
  110. apt-get clean
  111. '''.format(args = args)))
  112. @cmd(arg("--tag", default = "baiji:jessie",
  113. help = "tag of base docker image to use"),
  114. arg("--dsc", type = argparse.FileType("r"),
  115. help = ".dsc file to build"),
  116. arg("--local-package", default = [], nargs = "+",
  117. help = "local packages to make available to build"),
  118. arg("--force-image", action = "store_true",
  119. help = "don't rebuild Docker image"),
  120. arg("--dont-clean", action = "store_true",
  121. help = "don't clean up old Docker images"),
  122. arg("--just-image", action = "store_true",
  123. help = "don't build, just generate Docker image"),
  124. )
  125. def build(args):
  126. """
  127. Build a binary package given a source package.
  128. If no source package supplied, try to build one from the current
  129. directory, like debuild.
  130. """
  131. if args.dsc is None:
  132. try:
  133. subprocess.check_call(("dpkg-buildpackage", "-S", "-us", "-uc", "-rfakeroot"))
  134. except Exception as e:
  135. sys.exit("Couldn't build source package: {!s}".format(e))
  136. try:
  137. with open("debian/changelog") as f:
  138. changelog = debian.changelog.Changelog(f)
  139. args.dsc = open("../{}_{}{}.dsc".format(
  140. changelog.package, changelog.upstream_version,
  141. "" if changelog.debian_revision is None else "-" + changelog.debian_revision))
  142. except Exception as e:
  143. sys.exit("Couldn't find .dsc file: {!s}".format(e))
  144. dsc = debian.deb822.Dsc(args.dsc)
  145. dummy = debian.deb822.Deb822()
  146. dummy_name = "baiji-depends-" + dsc["Source"]
  147. dummy_fn = "{}_{}_all.deb".format(dummy_name, dsc["Version"])
  148. dummy["Depends"] = ", ".join(dsc[i]
  149. for i in ("Build-Depends",
  150. "Build-Depends-Indep",
  151. "Build-Depends-Arch")
  152. if i in dsc)
  153. dummy["Package"] = dummy_name
  154. for tag in ("Version", "Maintainer", "Homepage"):
  155. dummy[tag] = dsc[tag]
  156. build_image_hash = hashlib.sha1(dummy["Depends"]).hexdigest()
  157. build_image_name = "baiji/build/{}".format(dsc["Source"])
  158. build_image_vers = "{}-{}".format(dsc["Version"], build_image_hash)
  159. build_image = "{}:{}".format(build_image_name, build_image_vers)
  160. with Docker("image", "ls", build_image_name, "--format", "{{.Tag}}",
  161. stdout = subprocess.PIPE) as docker:
  162. build_image_versions = set(docker.stdout.read().split())
  163. build_image_exists = build_image_vers in build_image_versions
  164. make_build_image = args.force_image or not build_image_exists
  165. if args.dont_clean:
  166. build_image_versions = { build_image_vers } if build_image_exists else set()
  167. if not make_build_image:
  168. build_image_versions.discard(build_image_vers)
  169. for v in build_image_versions:
  170. with Docker("rmi", "{}:{}".format(build_image_name, v)):
  171. pass
  172. if make_build_image:
  173. with tempdir() as dn:
  174. equivs = subprocess.Popen(("equivs-build", "/dev/stdin"),
  175. stdin = subprocess.PIPE, stdout = subprocess.PIPE, cwd = dn)
  176. equivs.communicate(str(dummy))
  177. if equivs.wait():
  178. sys.exit("Couldn't generate dummy dependency package")
  179. with open(os.path.join(dn, "Dockerfile"), "w") as f:
  180. f.write(textwrap.dedent('''\
  181. FROM {args.tag}
  182. COPY build.sh /baiji/
  183. COPY micro-apt /micro-apt/
  184. RUN cd /micro-apt && \\
  185. apt-ftparchive packages . > Packages
  186. RUN cd /etc/apt/sources.list.d && \\
  187. echo 'deb [trusted=yes] file:///micro-apt ./' > micro-apt.list
  188. RUN apt-get update && \\
  189. apt-get install -y --no-install-recommends {dummy_name} && \\
  190. apt-get clean
  191. USER baiji
  192. '''.format(args = args, dummy_name = dummy_name)))
  193. with open(os.path.join(dn, "build.sh"), "w") as f:
  194. f.write(textwrap.dedent('''\
  195. #!/bin/bash -
  196. set -eo pipefail
  197. arch=`dpkg-architecture -qDEB_BUILD_ARCH`
  198. dpkg-source -x /source/{source}_{version}.dsc {source}-{version}
  199. cd {source}-{version}
  200. dpkg-buildpackage -b -uc -us 2>&1 | tee ../{source}_{version}_$arch.build
  201. cd ..
  202. rm -rf {source}-{version}
  203. '''.format(source = dsc["Source"], version = dsc["Version"])))
  204. with Docker("build", "-t", build_image, "-", stdin = subprocess.PIPE) as docker:
  205. with tarfile.open(mode = "w|", fileobj = docker.stdin) as tar:
  206. for fn in ("Dockerfile", "build.sh"):
  207. tar.add(os.path.join(dn, fn), fn, filter = fakeroot_filter)
  208. for pkg in [os.path.join(dn, dummy_fn)] + args.local_package:
  209. tar.add(pkg, os.path.join("micro-apt", os.path.basename(pkg)),
  210. filter = fakeroot_filter)
  211. if not args.just_image:
  212. container_name = "baiji-build-{}".format(dsc["Source"])
  213. dn = os.path.dirname(args.dsc.name)
  214. with Docker("run", "-i", "--name", container_name, "--network", "none",
  215. "-v", "{}:/source:ro".format(os.path.abspath(dn)),
  216. build_image, "/bin/bash", "-x", "/baiji/build.sh"):
  217. pass
  218. with Docker("cp", "{}:/build/.".format(container_name), "-",
  219. stdout = subprocess.PIPE) as docker:
  220. with tarfile.open(mode = "r|*", fileobj = docker.stdout) as tar:
  221. for member in tar:
  222. fn = os.path.basename(member.name)
  223. if any(fn.endswith(fn2) for fn2 in (".deb", ".changes")):
  224. with open(os.path.join(dn, fn), "w") as f:
  225. f.write(tar.extractfile(member).read())
  226. with Docker("rm", container_name):
  227. pass
  228. # Parse arguments and dispatch to one of the commands above.
  229. def main():
  230. HF = type("HF", (argparse.ArgumentDefaultsHelpFormatter,
  231. argparse.RawDescriptionHelpFormatter), {})
  232. parser = argparse.ArgumentParser(formatter_class = HF, description = __doc__)
  233. subparsers = parser.add_subparsers(title = "Commands", metavar = "")
  234. for name in sorted(globals()):
  235. func = globals()[name]
  236. try:
  237. setup_parser = func._setup_parser
  238. except:
  239. continue
  240. setup_parser(subparsers.add_parser(name.replace("_", "-"),
  241. formatter_class = HF,
  242. description = func.__doc__,
  243. help = (func.__doc__ or "").lstrip().partition("\n")[0]))
  244. args = parser.parse_args()
  245. args.func(args)
  246. if __name__ == "__main__":
  247. main()