#!/usr/bin/ruby

# whalebuilder - Debian package builder using Docker
# Copyright (C) 2015-2018 Hubert Chathi <hubert@uhoreg.ca>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

require 'tmpdir'
require 'optparse'
require 'gpgme'
require 'debian'
require 'erb'
require 'fileutils'
require 'etc'
require 'socket'

SHARE_DIR = begin
              basedir = File.dirname(File.realpath(__FILE__))
              if File.exists? File.join(basedir, "Dockerfile.base.erb")
                basedir
              else
                "/usr/share/whalebuilder"
              end
            end
DEFAULT_CONF_FILE = "/etc/whalebuilder.conf"

# common class for templated files
class Templater
  def write(filename)
    b = binding
    result = ERB.new(File.read(File.join(SHARE_DIR, self.class::TEMPLATE_FILE)),
                     nil, "-").result b
    File.open(filename, "w") do |file|
      file.write result
    end
  end
end

DOCKER_CMD = begin
               socket = (ENV["DOCKER_HOST"] || "unix:///var/run/docker.sock").dup
               if socket.slice! "unix://" and not File.stat(socket).writable?
                 ["sudo", "docker"]
               else
                 ["docker"]
               end
             end

def make_docker_command(*args)
  DOCKER_CMD + args
end

# helper function for calling docker
def docker(*args)
  system(*(make_docker_command(*args)))
end

def docker_rm(*args)
  docker "rm", *args, :out => "/dev/null" or
    warn "[whalebuilder] W: unable to remove docker container (#{$?})"
end

#global options
options = {}
options[:config] = DEFAULT_CONF_FILE
global_opt_parser = OptionParser.new do |opts|
  opts.banner = "Debian package builder using Docker
Usage: #{opts.program_name} <command> [options]"
  #opts.separator ""
  #opts.separator "Global options:"
  #opts.on "-c", "--config FILE", "configuration file (default: #{DEFAULT_CONF_FILE}) (not used)" do |v|
  #  options[:config] = v
  #end
  opts.separator ""
  opts.separator "Commands:"
  opts.separator "    create - create a Docker image"
  opts.separator "    update - update a Docker image"
  opts.separator "    build - build a package"
  opts.separator "    run - run a command from a package inside a Docker image"
  opts.separator "    help - show this help"
  opts.separator ""
  opts.separator "See '#{opts.program_name} <command> --help' for help about a specific command"
end
global_opt_parser.order!

if ARGV.length == 0
  global_opt_parser.abort "Error: no command specified"
end

command = ARGV.shift

case command
when 'create'
  ##############################################################################
  # Create an image
  ##############################################################################
  options[:distribution] = "docker.io/debian"
  options[:release] = "sid"
  options[:repository] = nil
  options[:debootstrap] = false
  options[:hooks] = []
  options[:eatmydata] = false

  def guess_mailname
    File.open('/etc/mailname', 'r').read.strip rescue nil ||
    Socket.gethostbyname(Socket.gethostname).first rescue nil ||
    `uname -n`.strip
  end

  deb_email = ENV["DEBEMAIL"] ||
              ENV["EMAIL"] ||
              "#{Etc.getlogin}@#{guess_mailname}"
  deb_name = ENV["DEBFULLNAME"] ||
             ENV["NAME"] ||
             Etc.getpwuid.gecos.split(',').first rescue nil ||
             "Nobody"
  options[:maintainer] = "#{deb_name} <#{deb_email}>"

  create_opt_parser = OptionParser.new do |opts|
    opts.banner = "Create a Docker image for building packages
Usage: #{opts.program_name} [globalopts] create [options] <image name>"
    opts.separator ""
    opts.separator "Create options:"
    opts.on "-d", "--distribution NAME", "distribution name.  This should match a Docker image name (default: docker.io/debian)" do |v|
      options[:distribution] = v
    end
    opts.on "-r", "--release NAME", "release name.  This should match a tag for the base Docker image (default: sid)" do |v|
      options[:release] = v
    end
    opts.on "--repository URL", "apt repository to use (default: use the base image's apt repository, or http://deb.debian.org/debian for debootstrap)" do |v|
      options[:repository] = v
    end
    opts.on "--maintainer NAME", "maintainer name and address (default: uses DEBEMAIL/DEBFULLNAME or try to guess from login and hostname)" do |v|
      options[:maintainer] = v
    end
    opts.on "--[no-]debootstrap", "use debootstrap to build image, rather than Docker's base Debian image" do |v|
      options[:debootstrap] = v
    end
    opts.on "--hook HOOK", "add an additional Dockerfile instruction when building base image" do |v|
      options[:hooks] << v
    end
    opts.on "--[no-]eat-my-data", "Use eatmydata to speed up building (default: false) NOTE: this does not have much effect on debootstrap builds." do |v|
        options[:eatmydata] = v
    end
    opts.separator ""
    opts.separator "Hint: You can use a pre-built image, such as docker.io/whalebuilder/debian:* (see https://hub.docker.com/r/whalebuilder/debian/) rather than building your own."
  end

  create_opt_parser.parse! ARGV

  if ARGV.length == 0
    global_opt_parser.abort "Error: image name not specified"
  end

  if ARGV.length > 1
    global_opt_parser.abort "Error: extra arguments found"
  end

  if options[:debootstrap]
    Dir.mktmpdir do |dir|
      # Execute first debootstrap stage
      puts "[whalebuilder] I: debootstrap first stage"
      args = ["fakeroot", "debootstrap", "--foreign", "--variant=buildd"]
      args << options[:release]
      args << File.join(dir, "stage1")
      args << (options[:repository] or "http://deb.debian.org/debian")
      system(*args,
             :out => ["/dev/null", "w"]) or abort "[whalebuilder] E: debootstrap failed with code #{$?}"

      # Import the result into a stage1 docker image
      r, w = IO.pipe
      puts "[whalebuilder] I: import into docker"
      pid = spawn "fakeroot", "tar", "-C", File.join(dir, "stage1"), "-cf", "-", ".",
                  :out => w
      pid or abort "[whalebuilder] E: unable to spawn tar"
      w.close
      docker "import", "-", "#{ARGV[0]}-stage1",
             :in => r,
             :out => ["/dev/null", "w"] or abort "[whalebuilder] E: docker import failed with code #{$?}"
      r.close
      Process.wait pid
      $? == 0 or abort "[whalebuilder] E: error while exporting to docker import (code #{$?})"
    end

  end

  Dir.mktmpdir do |dir|
    class Dockerfile < Templater
      TEMPLATE_FILE = "Dockerfile.base.erb"
      def initialize(options)
        @debootstrap = options[:debootstrap]
        if @debootstrap
          @distribution, @tag = "#{ARGV[0]}-stage1".split ":", 2
        else
          @distribution = options[:distribution]
          @tag = options[:release]
        end
        @maintainer = options[:maintainer]
        @repository = options[:repository]
        @hooks = options[:hooks]
        @eatmydata = options[:eatmydata]
      end
    end
    Dockerfile.new(options).write(File.join(dir, "Dockerfile"))
    args = ["build", "--tag=#{ARGV[0]}"]
    args << "--pull=true" unless options[:debootstrap]
    args << dir
    docker(*args)
  end

when 'update'
  ##############################################################################
  # Update an existing image
  ##############################################################################
  options[:reimport] = false
  options[:eatmydata] = false
  update_opt_parser = OptionParser.new do |opts|
    opts.banner = "Update a Docker image for building packages
Usage: #{opts.program_name} [globalopts] update [options] <image name>"
    opts.separator ""
    opts.on "--[no-]reimport", "re-import the Docker image rather than layering on top" do |v|
      options[:reimport] = v
    end
    opts.on "--[no-]eat-my-data", "Use eatmydata to speed up building (default: false) NOTE: eatmydata must already be installed in the image" do |v|
        options[:eatmydata] = v
    end
  end

  update_opt_parser.parse! ARGV

  if ARGV.length == 0
    global_opt_parser.abort "Error: image name not specified"
  end

  if ARGV.length > 1
    global_opt_parser.abort "Error: extra arguments found"
  end

  Dir.mktmpdir do |dir|
    args = ["run", "--cidfile=#{File.join(dir, 'cid')}",
            ARGV[0]]
    if options[:eatmydata]
      args << "eatmydata"
    end
    args.concat ["sh", "-c", "apt-get update && apt-get -y dist-upgrade && apt-get autoremove && apt-get clean"]
    docker(*args) or
      abort "[whalebuilder] E: unable to update image #{ARGV[0]}"
    container = IO.read(File.join(dir, 'cid')).chomp
    if options[:reimport]
      r, w = IO.pipe
      pid = spawn(*(make_docker_command "export", container,
                                        :out => w))
      pid or abort "[whalebuilder] E: unable to spawn docker export"
      w.close
      docker "import", "-", ARGV[0],
             :in => r,
             :out => ["/dev/null", "w"] or abort "[whalebuilder] E: docker import failed with code #{$?}"
      r.close
      Process.wait pid
      $? == 0 or abort "[whalebuilder] E: error while exporting to docker import (code #{$?})"
    else
      docker "commit", container, "#{ARGV[0]}" or
        abort "[whalebuilder] E: unable to commit modifications to #{ARGV[0]}"
    end
    docker_rm container
  end

when 'build'
  ##############################################################################
  # Build a package
  ##############################################################################
  options[:results] = "~/.cache/whalebuilder"
  options[:pull] = false
  options[:cache] = true
  options[:install_depends] = true
  options[:remove] = false
  options[:hooks] = []
  options[:extra_debs] = []
  options[:image_name] = "docker.io/whalebuilder/debian:sid"
  options[:network] = "none"
  options[:verify] = false
  options[:eatmydata] = false
  options[:docker_opts] = []
  options[:source_only] = true
  options[:use_volume] = true
  options[:build_profiles] = []

  build_opt_parser = OptionParser.new do |opts|
    opts.banner = "Build a package
Usage: #{opts.program_name} [globalopts] build [options] [image name] <dsc file> [-- <dpkg-buildpackage arguments>]"
    opts.separator ""
    opts.separator "Build options:"
    opts.on "--results DIR", "directory to store the results (default: ~/.cache/whalebuilder)" do |v|
      options[:results] = v
    end
    opts.on "--[no-]rm", "remove dependency image (default: false)" do |v|
      options[:remove] = v
    end
    opts.on "--[no-]pull", "pull latest version of image (default: false)" do |v|
      options[:pull] = v
    end
    opts.on "--[no-]cache", "use docker cache when building image (default: true)" do |v|
      options[:cache] = v
    end
    opts.on "--[no-]install-depends", "install dependencies (default: true)" do |v|
      options[:install_depends] = v
    end
    opts.on "--hook HOOK", "add an additional Dockerfile instruction inserted after unpacking base image" do |v|
      options[:hooks] << v
    end
    opts.on "--deb DEB_FILE", "install a .deb package before installing other dependencies" do |v|
      options[:extra_debs] << v
    end
    opts.on "--network NETWORK", "enable network NETWORK, e.g. 'bridge'. By default network access is disabled ('none')." do |v|
        options[:network] = v
    end
    opts.on "--[no-]verify", "verify the signature on the dsc file (default: false) NOTE: the signing key must be in your own public keyring." do |v|
        options[:verify] = v
    end
    opts.on "--[no-]eat-my-data", "Use eatmydata to speed up building (default: false)" do |v|
        options[:eatmydata] = v
    end
    opts.on "--[no-]source-only-changes", "Build as a source-only upload (default: true)" do |v|
        options[:source_only] = v
    end
    opts.on "--[no-]use-volume", "Use a volume to insert the source into the container. (default: true) This will be slightly faster, but may fail under certain situations, such as if Docker is being run on a different host." do |v|
        options[:use_volume] = v
    end
    opts.on "-P", "--build-profile PROFILE", "value for DEB_BUILD_PROFILES." do |v|
        options[:build_profiles] << v
    end
    opts.separator ""
    opts.separator "If the image name is omitted, it will default to docker.io/whalebuilder/debian:sid."
  end

  build_opt_parser.parse! ARGV

  if ARGV.length == 0 or ARGV[0].start_with? '-'
    global_opt_parser.abort "Error: image name and dsc not specified"
  end

  dsc = ARGV.shift

  if ARGV.length >= 1 and not ARGV[0].start_with? '-'
    options[:image_name] = dsc
    dsc = ARGV.shift
  end

  if ARGV.length >= 1 and not ARGV[0].start_with? '-'
    global_opt_parser.abort "Error: extra arguments found"
  end

  # ARGV gets passed as arguments to dpkg-buildpackage
  ARGV << "--changes-option=-S" if options[:source_only]
  ARGV << "--build-profiles=" + options[:build_profiles].join(",") if options[:build_profiles].any?

  dscdir = File.dirname dsc

  options[:results] = File.expand_path options[:results]
  FileUtils.mkdir_p options[:results]

  Dir.mktmpdir do |dir|
    # parse dsc file
    dsccontents = File.read(dsc)
    if options[:verify] and
      dsccontents.start_with? "-----BEGIN PGP SIGNED MESSAGE-----\n"
      crypto = GPGME::Crypto.new
      signature = GPGME::Data.new dsccontents
      sigout = GPGME::Data.new
      # FIXME: load keys from /usr/share/keyrings/debian-keyring.gpg, if
      # present
      crypto.verify(signature, :output => sigout) do |sig|
        abort sig.to_s if !sig.valid?
      end

      dsccontents = sigout.to_s
    end

    dscfile = Debian::Dsc.new(dsccontents)
    escaped_package = dscfile.package.gsub(/[^A-Za-z0-9.-]/) {|s| "_" + s.ord.to_s(16) }
    escaped_version = dscfile.version.gsub(/[^A-Za-z0-9.-]/) {|s| "_" + s.ord.to_s(16) }
    cpu = `dpkg-architecture -qDEB_HOST_ARCH_CPU`.chomp
    os = `dpkg-architecture -qDEB_HOST_ARCH_OS`.chomp
    depends = Array(dscfile["Build-Depends"]) + Array(dscfile["Build-Depends-Indep"])
    conflicts = Array(dscfile["Build-Conflicts"]) + Array(dscfile["Build-Conflicts-Indep"])

    def filter_profile(dependencies, profiles)
      dependencies.filter_map { |dependency_entry|
        dependency_entry.gsub(/([^,|<]+)\s+((\s*<[^>]+>)+)/) { |c|
          dep = $1
          formula = $2
          passes = false
          formula.scan(/<([^>]+)>/) {
            list = $1
            positives = list.scan(/(?<=\s|\A)[^\s!]+/)
            negatives = list.scan(/(?<=!)[^\s]+/)
            if (positives - profiles).empty? and (negatives & profiles).empty?
              passes = true
            end
          }
          if passes
            dep
          else
            nil
          end
        }
      }
    end
    # We need to filter out arch-specific dependencies
    def filter_arch(dependencies, cpu, os)
      dependencies.filter_map { |dependency_entry|
        dependency_entry.gsub(/([^,|\[]+)\s+\[([^\]]+)\]/) { |c|
          dep = $1
          archs = $2.split(/\s+/)
          if archs.all? { |a| a.start_with? '!' }
            # Negative
            if archs.include? "!#{cpu}" or archs.include? "!#{os}" or
              archs.include? "!#{os}-any" or archs.include? "!any-#{cpu}"
              nil
            else
              dep
            end
          else
            # Positive
            if archs.include? cpu or archs.include? os or
              archs.include? "#{os}-any" or archs.include? "any-#{cpu}"
              dep
            else
              nil
            end
          end
        }
      }
    end
    depends = filter_profile(depends, options[:build_profiles])
    conflicts = filter_profile(conflicts, options[:build_profiles])
    depends = filter_arch(depends, cpu, os).join(", ")
    conflicts = filter_arch(conflicts, cpu, os).join(", ")

    # create image with build dependencies installed
    if options[:install_depends]
      puts "[whalebuilder] I: building Docker image with build dependencies"
      # build a package that depends on the build dependencies
      class EquivControl < Templater
        TEMPLATE_FILE = "whalebuilder-dependency-helper.ctl.erb"
        def initialize(dsc, depends, conflicts)
          @arch = `dpkg-architecture -qDEB_HOST_ARCH`.chomp
          @dsc = dsc
          @depends = depends
          @conflicts = conflicts
        end
      end
      EquivControl.new(dscfile, depends, conflicts).
        write(File.join(dir, "control"))
      Dir.chdir(dir) do
        File.write("debian-binary", "2.0\n")
        FileUtils.cp File.join(SHARE_DIR, "data.tar.gz"), File.join(dir, "data.tar.gz")
        reproducible_args = [ "--mtime=Sun Sep 27 16:03:31 UTC 2015",
                              "--numeric-owner", "--owner=root",
                              "-I", "gzip --no-name",
                              "--no-recursion" ]
        system "tar", "-cf", "control.tar.gz",
               *reproducible_args,
               "./control" or
          abort "[whalebuilder] E: cannot create control.tar.gz (#{$?})"
        system "ar", "qD", "whalebuilder-dependency-helper_1.0_all.deb",
               "debian-binary", "control.tar.gz", "data.tar.gz" or # order is important
          abort "[whalebuilder] E: cannot create dependency package (#{$?})"
        system "dpkg", "-I", "whalebuilder-dependency-helper_1.0_all.deb" or
          abort "[whalebuilder] E: dependency package is not correctly built (#{$?})"
        system "touch", "-d", "Sun Sep 27 16:03:31 UTC 2015",
               "whalebuilder-dependency-helper_1.0_all.deb"
      end

      if not options[:extra_debs].empty?
        FileUtils.cp options[:extra_debs], dir
      end

      # create the image
      newname = "whalebuilder_build/#{escaped_package}:#{escaped_version}"

      class Dockerfile < Templater
        TEMPLATE_FILE = "Dockerfile.build.erb"
        def initialize(options)
          @basename = options[:image_name]
          @hooks = options[:hooks]
          @extra_debs = options[:extra_debs].map { |x| File.basename x }
          @eatmydata = options[:eatmydata]
        end
      end
      Dockerfile.new(options).write(File.join(dir, "Dockerfile"))
      args = ["build", "--tag=#{newname}"]
      args << "--pull" if options[:pull]
      args << "--no-cache" unless options[:cache]
      args << dir
      docker(*args) or
        abort "[whalebuilder] E: docker build failed with error code #{$?}"

      options[:image_name] = newname
    end

    # copy source files
    FileUtils.mkdir File.join(dir, "source")
    files = dscfile["Files"].split("\n")
    files.map! do |x| x.length != 0 && File.join(dscdir, x.split()[2]) end
    files[0] = dsc
    FileUtils.cp files, File.join(dir, "source")

    # create script to build the package
    class BuildScript < Templater
      TEMPLATE_FILE = "build.sh.erb"
      def initialize(dscfilename, dscfile)
        @dscfilename = File.basename dscfilename
        @dscfile = dscfile
        @buildpackage_args = ARGV
      end
    end
    BuildScript.new(dsc, dscfile).write(File.join(dir, "source", "build.sh"))
    File.chmod 0755, File.join(dir, "source", "build.sh")

    puts "[whalebuilder] I: building package"
    containername = "whalebuilder_build_#{escaped_package}_#{escaped_version}"
    target = "#{options[:results]}/#{dscfile.package}_#{dscfile.version}"
    FileUtils.mkdir_p target
    # remove stale container
    r, w = IO.pipe
    if docker "inspect", "-f", "{{.State.Running}}", containername, :out => w, :err => "/dev/null"
      w.close
      if r.read.strip == "false"
        docker "rm", containername
      else
        abort "[whalebuilder] E: package is currently being built.  Run \"docker rm -f #{containername}\" to kill the existing build."
      end
    else
      w.close
    end
    r.close
    # build the package
    cmd = ["run", "-d", "-i",
           "--name=#{containername}",
           "-w", "/build",
           "--network=#{options[:network]}"]
    if options[:use_volume]
      cmd.concat ["-v", "#{dir}/source:/home/whalebuilder/source:ro"]
    end
    cmd.concat options[:docker_opts]
    cmd.concat [options[:image_name], "/bin/sh"]
    docker(*cmd) or
      abort "[whalebuilder] E: failed to start container with error #{$?}"
    if not options[:use_volume]
      docker("exec", containername, "mkdir", "/home/whalebuilder/source")
      r, w = IO.pipe
      spawn "tar", "-cf", "-", ".",
             :out => w,
             :chdir => "#{dir}/source" or
        abort "[whalebuilder] E: docker cp failed with error #{$?}"
      w.close
      system(*(make_docker_command "cp", "-","#{containername}:/home/whalebuilder/source"), :in => r) or
        abort "[whalebuilder] E: docker cp failed with error #{$?}"
      r.close
      puts "[whalebuilder] I: copied sources to container"
    end
    cmd = ["exec", "--user=whalebuilder", containername]
    if options[:eatmydata]
      cmd << "eatmydata"
    end
    cmd.concat ["/bin/bash", "/home/whalebuilder/source/build.sh"]
    unless docker(*cmd)
      warn "[whalebuilder] E: docker run failed with error #{$?}"
      arch = `dpkg-architecture -qDEB_BUILD_ARCH`.chomp
      docker "cp", "#{containername}:/build/#{dscfile.package}_#{dscfile.version}_#{arch}.build", target
      puts "[whalebuilder] I: copied build log to #{target}"
      print "Do you want to start a shell in the container? [Y/n] "
      resp = ($stdin.readline.lstrip[0] || "Y").downcase
      if resp == "n"
        docker_rm "-f", containername
      else
        docker "exec", "--user=whalebuilder", "-i", "-t", containername, "/bin/bash"
        docker_rm "-f", containername
      end
      abort
    end
    r, w = IO.pipe
    pid = spawn(*(make_docker_command "cp", "#{containername}:/build/.", "-"), :out => w)
    pid or abort "[whalebuilder] E: docker cp failed with error #{$?}"
    w.close
    system "tar", "-xf", "-", "--no-same-owner", "--no-same-permissions", "--strip-components=1",
           :in => r,
           :chdir => target or
      abort "[whalebuilder] E: docker cp failed with error #{$?}"
    r.close
    puts "[whalebuilder] I: copied build results to #{target}"
    IO.popen(make_docker_command "diff", containername) do |f|
      out = f.read.split(/\n/)
      out.select! do |x|
        !(x == "C /home" \
          || x == "C /home/whalebuilder" \
          || x == "A /home/whalebuilder/source" \
          || x == "C /tmp" \
          || x == "C /build" \
          || x.start_with?("A /build/"))
      end
      if out.length != 0
        warn "[whalebuilder] W: detected filesystem changes outside of build tree:"
        warn out
      end
    end
    docker_rm "-f", containername

    # remove build dependency image if requested, and only if we created it in
    # the first place
    if options[:remove] && options[:install_depends]
      docker "rmi", options[:image_name], :out => "/dev/null" or
        warn "[whalebuilder] W: unable to remove docker image #{name} (#{$?})"
    end
  end

when 'run'
  ##############################################################################
  # Run a command from a package
  ##############################################################################
  options[:debfiles] = []
  options[:packages] = []
  options[:pull] = false
  options[:cache] = true
  options[:x11] = nil
  options[:xephyr] = nil
  options[:xpra] = false
  options[:wm] = nil
  options[:hooks] = []
  options[:image_name] = "whalebuilder_run"
  options[:base_image_name] = "docker.io/debian:sid"

  run_opt_parser = OptionParser.new do |opts|
    opts.banner = "Run a command from a package
Usage: #{opts.program_name} [globalopts] run [options] <command> [command options] [-- <docker options>]"
    opts.separator ""
    opts.separator "Run options:"
    opts.on "--deb-file FILE", "install the .deb file" do |v|
      options[:debfiles] << v
    end
    opts.on "--package PACKAGE", "install the apt package" do |v|
      options[:packages] << v
    end
    opts.on "--deb FILEORPKG", "install the given .deb file or apt package (automatically detected)" do |v|
      if v.end_with? ".deb"
        options[:debfiles] << v
      else
        options[:packages] << v
      end
    end
    opts.on "--[no-]install-recommends", "install recommended packages (default: true)" do |v|
      options[:install_recommends] = v
    end
    opts.on "--base", "base image to use (default: docker.io/debian:sid)" do |v|
      options[:base_image_name] = v
    end
    opts.on "--[no-]pull", "pull latest version of image (default: false)" do |v|
      options[:pull] = v
    end
    opts.on "--[no-]cache", "use docker cache when building image (default: true)" do |v|
      options[:cache] = v
    end
    opts.on "--hook HOOK", "add an additional Dockerfile instruction inserted after the base image" do |v|
      options[:hooks] << v
    end
    opts.separator "X11 support:"
    opts.on "--x11 DISPLAY", "connect to the given X11 server (default: none)" do |v|
      options[:x11] = v
    end
    opts.on "--xephyr NUM", Integer, "launch Xephyr with the display number and connect to it" do |v|
      options[:xephyr] = v
    end
    opts.on "--[no-]xpra",  "use Xpra for the program's display" do |v|
      options[:xpra] = v
    end
    opts.on "--wm PROG", "launch a window manager when a new display is created (e.g. for Xephyr)" do |v|
      options[:wm] = v
    end
  end

  run_opt_parser.order! ARGV

  if ARGV.length == 0 or ARGV[0] == "--"
    global_opt_parser.abort "Error: command not specified"
  end

  idx = ARGV.index "--"
  if idx == nil
    command = ARGV
    docker_opts = []
  else
    command = ARGV.take(idx)
    docker_opts = ARGV.drop(idx + 1)
  end

  if ARGV.length >= 1 and ARGV[0].start_with? '-'
    global_opt_parser.abort "Error: extra arguments found"
  end

  if options[:xpra]
    options[:packages] << "xpra"
  end

  Dir.mktmpdir do |dir|
    if not options[:debfiles].empty?
      FileUtils.cp options[:debfiles], dir
    end

    class Dockerfile < Templater
      TEMPLATE_FILE = "Dockerfile.run.erb"
      def initialize(options)
        @basename = options[:base_image_name]
        @debs = options[:debfiles].map { |x| File.basename x }
        @packages = options[:packages]
        @hooks = options[:hooks]
        @install_recommends = options[:install_recommends]
      end
    end
    puts "[whalebuilder] I: building Docker image with packages"
    Dockerfile.new(options).write(File.join(dir, "Dockerfile"))
    args = ["build", "--tag=#{options[:image_name]}"]
    args << "--pull" if options[:pull]
    args << "--no-cache" unless options[:cache]
    args << dir
    docker(*args) or
      abort "[whalebuilder] E: docker build failed with error code #{$?}"
  end

  args = ["run", "-i", "-t", "--rm"]

  case
  when options[:xpra]
    Dir.mktmpdir do |dir|
      File.chmod(0711, dir)

      # generate password for authentication
      require 'securerandom'
      password = SecureRandom.urlsafe_base64
      envfilename = "#{dir}/env"
      envfile = File.new envfilename, "w", 0600
      envfile.write "XPRA_PASSWORD=#{password}"
      envfile.close
      passfilename = "#{dir}/password"
      passfile = File.new passfilename, "w", 0600
      passfile.write password
      passfile.close
      xpradir = "#{dir}/xpra"
      Dir.mkdir xpradir
      File.chmod 0777, xpradir

      # start Xpra in docker, spawning the specified command
      puts "[whalebuilder] I: running \"#{command.join(" ")}\" in container"
      command = ["xpra", "start", "--bind=auto", "--socket-dir=/xpra",
                 "--socket-permissions=666", "--start-child=#{command.join(" ")}",
                 "--exit-with-children", "--daemon=no", "--auth=env"]
      args = ["run", "-d", "--cidfile=#{File.join(dir, "cid")}", "-v", "#{xpradir}:/xpra", "--env-file", envfilename]
      args.concat docker_opts
      args << options[:image_name]
      args.concat command
      puts xpradir
      docker(*args) or
        abort "[whalebuilder] E: failed to start container, or command exited with error #{$?}"
      container = IO.read(File.join(dir, "cid")).chomp
      at_exit do
        docker_rm "-f", container
      end
      # wait for Xpra to create the socket (up to 10 minutes)
      for _i in 0..600
        entries = Dir.entries(xpradir)
        idx = entries.index { |x| File.socket?(File.join(xpradir, x)) }
        if idx != nil
          socket = File.join(xpradir, entries[idx])
          break
        end
        sleep 1
      end
      abort "[whalebuilder] E: timed out waiting for Xpra" if not socket
      # wait for the socket to be ready (up to 10 seconds)
      for _i in 0..10
        begin
          f = File.new socket, "r"
          f.close
          break
        rescue SystemCallError => e
          raise e if e.errno != Errno::ENXIO::Errno
        end
        sleep 1
      end

      system "xpra", "attach", "--password-file", passfilename, "socket:#{socket}"
    end
    exit
  when options[:xephyr]
    xephyr_pid = spawn "Xephyr", ":#{options[:xephyr]}", "-resizeable"
    xephyr_pid or abort "[whalebuilder] E: Xephyr failed with error #{$?}"
    at_exit do
      Process.kill("TERM", xephyr_pid)
    end
    # wait for Xephyr to start (up to a minute)
    for _i in 0..60
      if Process.wait(xephyr_pid, Process::WNOHANG)
        abort "[whalebuilder] E: Xephyr failed with error #{$?}"
      end
      break if File.exist? "/tmp/.X11-unix/X#{options[:xephyr]}"
      sleep 1
    end
    abort "[whalebuilder] E: timeout waiting for Xephyr" if not File.exist? "/tmp/.X11-unix/X#{options[:xephyr]}"
    if options[:wm]
      wm_pid = spawn({"DISPLAY" => ":#{options[:xephyr]}"}, options[:wm])
      # FIXME: redirect WM output to /dev/null?
      wm_pid or abort "[whalebuilder] E: failed to start window manager with error #{$?}"
      at_exit do
        Process.kill("TERM", wm_pid)
      end
    end
    args << "-e" << "DISPLAY=:#{options[:xephyr]}" << "-v" << "/tmp/.X11-unix/X#{options[:xephyr]}:/tmp/.X11-unix/X#{options[:xephyr]}"
  when options[:x11]
    args << "-e" << "DISPLAY=#{options[:x11]}"
  end

  args.concat docker_opts
  args << options[:image_name]
  args.concat command
  puts "[whalebuilder] I: running \"#{command.join(" ")}\" in container"
  docker(*args) or
    abort "[whalebuilder] E: failed to start container, or command exited with error #{$?}"

when 'help'
  puts global_opt_parser

when 'moo'
  require "base64"
  require "zlib"
  puts Zlib::Inflate.inflate(Base64.decode64("eJx9T8ENgCAM/DNFw0cNFDdwA+MCJLqBC3R424KAUWwftPR6vTPQDYfO9KcEpNP9jnZPMjWBQX4osyPUdc1URwOjlut56js1RJmMseRR+I5Qj9KDLTNG/Vye6iMJIzakiNayNLTD2yd9WJ7fsCgiPGz/qCyohMePi+wLizFBXHlbRg0="))

else
  ##############################################################################
  # everything else
  ##############################################################################
  puts global_opt_parser
  puts ""
  global_opt_parser.abort "Error: unknown command #{command}"
end
