#!/usr/bin/ruby

# whalebuilder - Debian package builder using Docker
# Copyright (C) 2015-2017 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

def make_docker_command (*args)
  socket = ENV["DOCKER_HOST"] || "unix:///var/run/docker.sock"
  if socket.slice! "unix://" and not File.stat(socket).writable?
    ["sudo", "docker"] + args
  else
    ["docker"] + args
  end
end

# helper function for calling docker
def docker (*args)
  system(*(make_docker_command(*args)))
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} [globalopts] <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 ""
  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] = "debian"
  options[:release] = "sid"
  options[:repository] = nil
  options[:debootstrap] = false
  options[:hooks] = []

  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: 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: http://httpredir.debian.org/debian)" 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.separator ""
    opts.separator "Hint: You may wish to use a pre-built image, such as uhoreg/whalebuilder-base:* (see https://registry.hub.docker.com/u/uhoreg/whalebuilder-base/) 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://httpredir.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]
      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
  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
  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|
    docker "run", "--cidfile=#{File.join(dir, 'cid')}",
           "#{ARGV[0]}",
           "sh", "-c", "apt-get update && apt-get -y dist-upgrade && apt-get autoremove && apt-get clean" or
      abort "[whalebuilder] E: unable to update image #{ARGV[0]}"
    if options[:reimport]
      r, w = IO.pipe
      pid = spawn(*(make_docker_command "export", IO.read(File.join(dir, 'cid')).chomp,
                                        :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", "#{IO.read(File.join(dir, 'cid')).chomp}", "#{ARGV[0]}" or
        abort "[whalebuilder] E: unable to commit modifications to #{ARGV[0]}"
    end
    docker "rm", "#{IO.read(File.join(dir, 'cid')).chomp}", :out => "/dev/null" or
      warn "[whalebuilder] W: unable to remove docker container #{IO.read(File.join(dir, 'cid')).chomp} (#{$?})"
  end

when 'build'
  ##############################################################################
  # Build a package
  ##############################################################################
  options[:results] = "~/.local/share/whalebuilder"
  options[:pull] = false
  options[:cache] = true
  options[:install_depends] = true
  options[:remove] = false
  options[:hooks] = []
  options[:extra_debs] = []

  build_opt_parser = OptionParser.new do |opts|
    opts.banner = "Build a package
Usage: #{opts.program_name} [globalopts] build [options] <image name> <dsc file>"
    opts.separator ""
    opts.separator "Build options:"
    opts.on "--results DIR", "directory to store the results (default: ~/.local/share/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 "--[no-]rm", "remove dependency image (default: false)" do |v|
      options[:remove] = 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
  end

  build_opt_parser.parse! ARGV

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

  if ARGV.length == 1
    global_opt_parser.abort "Error: dsc not specified"
  end

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

  name = ARGV.shift
  dsc = ARGV.shift
  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 (dsccontents.start_with? "-----BEGIN PGP SIGNED MESSAGE-----\n")
      crypto = GPGME::Crypto.new
      signature = GPGME::Data.new dsccontents
      sigout = GPGME::Data.new
      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"])

    # We need to filter out arch-specific dependencies
    def filter_arch (dependencies, cpu, os)
      dependencies.gsub(/(?:(\s*[|,]\s*))?([^,|\[]+)\s+\[([^\]]+)\]/) { |c|
        sep = $1
        dep = $2
        archs = $3.split(/\s+/)
        if archs.all? { |a| a[0] == "!" }
          # Negative
          if archs.include? "!#{cpu}" or archs.include? "!#{os}" or
            archs.include? "!#{os}-any" or archs.include? "!any-#{cpu}"
            nil
          else
            "#{sep}#{dep}"
          end
        else
          # Positive
          if archs.include? cpu or archs.include? os or
            archs.include? "#{os}-any" or archs.include? "any-#{cpu}"
            "#{sep}#{dep}"
          else
            nil
          end
        end
      }
    end
    depends = filter_arch(depends.join(", "), cpu, os)
    conflicts = filter_arch(conflicts.join(", "), cpu, os)

    # 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 (basename, options)
          @basename = basename
          @hooks = options[:hooks]
          @extra_debs = options[:extra_debs].map { |x| File.basename x }
        end
      end
      Dockerfile.new(name, 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 #{$?}"

      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
      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 #{containername}\" to kill the existing build."
      end
    else
      w.close
    end
    r.close
    # build the package
    docker "run", "--user=whalebuilder", "--name=#{containername}",
           "-v", "#{dir}/source:/home/whalebuilder/source:ro",
           "--net=none", name, "/bin/bash", "/home/whalebuilder/source/build.sh" or
      abort "[whalebuilder] E: docker run failed with error #{$?}"
    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", "-x", "--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 #{options[:results]}/#{dscfile.package}_#{dscfile.version}"
    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", containername, :out => "/dev/null" or
      warn "[whalebuilder] W: unable to remove docker container #{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", name, :out => "/dev/null" or
        warn "[whalebuilder] W: unable to remove docker image #{name} (#{$?})"
    end
  end
when 'moo'
  require "base64"
  require "zlib"
  puts Zlib::Inflate.inflate(Base64.decode64("eJx9T8ENgCAM/DNFw0cNFDdwA+MCJLqBC3R424KAUWwftPR6vTPQDYfO9KcEpNP9jnZPMjWBQX4osyPUdc1URwOjlut56js1RJmMseRR+I5Qj9KDLTNG/Vye6iMJIzakiNayNLTD2yd9WJ7fsCgiPGz/qCyohMePi+wLizFBXHlbRg0="))
else
  ##############################################################################
  # everything else
  ##############################################################################
  global_opt_parser.abort "Error: unknown command #{command}"
end
