ripta/drydock

View on GitHub
lib/drydock/project.rb

Summary

Maintainability
B
6 hrs
Test Coverage

module Drydock
  # A project defines the methods available in a `Drydockfile`. When run using
  # the binary `drydock`, this object will be instantiated automatically for you.
  #
  # The contents of a `Drydockfile` is automatically evaluated in the context
  # of a project, so you don't need to instantiate the object manually.
  class Project

    DEFAULT_OPTIONS = {
      auto_remove: true,
      author: nil,
      cache: nil,
      event_handler: false,
      ignorefile: '.dockerignore'
    }

    attr_reader :chain

    # Create a new project. **Do not use directly.**
    #
    # @api private
    # @param [Hash] build_opts Build-time options
    # @option build_opts [Boolean] :auto_remove Whether intermediate images
    #   created during the build of this project should be automatically removed.
    # @option build_opts [String] :author The default author field when an
    #   author is not provided explicitly with {Project#author}.
    # @option build_opts [ObjectCaches::Base] :cache An object cache manager.
    # @option build_opts [#call] :event_handler A handler that responds to a
    #   `#call` message with four arguments: `[event, is_new, serial_no, event_type]`
    #   most useful to override logging.
    # @option build_opts [PhaseChain] :chain A phase chain manager.
    # @option build_opts [String] :ignorefile The name of the ignore-file to load.
    def initialize(build_opts = {})
      @chain   = build_opts.key?(:chain) && build_opts.delete(:chain).derive
      @plugins = {}

      @run_path = []
      @serial   = 0

      @build_opts = DEFAULT_OPTIONS.clone
      build_opts.each_pair { |key, value| set(key, value) }

      @stream_monitor = build_opts[:event_handler] ? StreamMonitor.new(build_opts[:event_handler]) : nil
    end

    # Set the author for commits. This is not an instruction, per se, and only
    # takes into effect after instructions that cause a commit.
    #
    # This instruction affects **all instructions after it**, but nothing before it.
    #
    # At least one of `name` or `email` must be given. If one is provided, the
    # other is optional.
    #
    # If no author instruction is provided, the author field is left blank by default.
    #
    # @param [String] name The name of the author or maintainer of the image.
    # @param [String] email The email of the author or maintainer.
    # @raise [InvalidInstructionArgumentError] when neither name nor email is provided
    def author(name: nil, email: nil)
      if (name.nil? || name.empty?) && (email.nil? || name.empty?)
        fail InvalidInstructionArgumentError, 'at least one of `name:` or `email:` must be provided'
      end

      value = email ? "#{name} <#{email}>" : name.to_s
      set :author, value
    end

    # Retrieve the current build ID for this project. If no image has been built,
    # returns the string '0'.
    def build_id
      chain ? chain.serial : '0'
    end

    # Change directories for operations that require a directory.
    #
    # @param [String] path The path to change directories to.
    # @yield block containing instructions to run inside the new directory
    def cd(path = '/', &blk)
      @run_path << path
      blk.call if blk
    ensure
      @run_path.pop
    end

    # Set the command that is automatically executed by default when the image
    # is run through the `docker run` command.
    #
    # {#cmd} corresponds to the `CMD` Dockerfile instruction. This instruction
    # does **not** run the command, but rather provides the default command to
    # be run when the image is run without specifying a command.
    #
    # As with the `CMD` Dockerfile instruction, the {#cmd} instruction has three
    # forms:
    #
    # * `['executable', 'param1', 'param2', '...']`, which would run the
    #   executable directly when the image is run;
    # * `['param1', 'param2', '...']`, which would pass the parameters to the
    #   executable provided in the {#entrypoint} instruction; or
    # * `'executable param1 param2'`, which would run the executable inside
    #   a subshell.
    #
    # The first two forms are preferred over the last one. See also {#entrypoint}
    # to see how the instruction interacts with this one.
    #
    # @param [String, Array<String>] command The command set to run. When a
    #   `String` is provided, the command is run inside a shell (`/bin/sh`).
    #   When an `Array` is given, the command is run as-is given.
    def cmd(command)
      requires_from!(:cmd)
      log_step('cmd', command)

      command = ['/bin/sh', '-c', command.to_s] unless command.is_a?(Array)
      chain.run("# CMD #{command.inspect}", command: command)
      self
    end

    # Copies files from `source_path` on the the build machine, into `target_path`
    # in the container. This instruction automatically commits the result.
    #
    # The `copy` instruction always respects the `ignorefile`.
    #
    # When `no_cache` is `true` (also see parameter explanation below), then any
    # instruction after {#copy} will also be rebuilt *every time*.
    #
    # @param [String] source_path The source path on the build machine (where
    #   `drydock` is running) from which to copy files.
    # @param [String] target_path The target path inside the image to which to
    #   copy the files. This path **must already exist** before copying begins.
    # @param [Integer, Boolean] chmod When `false` (the default), the original file
    #   mode from its source file is kept when copying into the container. Otherwise,
    #   the mode provided (in integer octal form) will be used to override *all*
    #   file and directory modes.
    # @param [Boolean] no_cache When `false` (the default), the hash digest of the
    #   source path--taking into account all its files, directories, and contents--is
    #   used as the cache key. When `true`, the image is rebuilt *every* time.
    # @param [Boolean] recursive When `true`, then `source_path` is expected to be
    #   a directory, at which point all its contents would be recursively searched.
    #   When `false`, then `source_path` is expected to be a file.
    #
    # @raise (see Instructions::Copy#run!)
    def copy(source_path, target_path, chmod: false, no_cache: false, recursive: true)
      requires_from!(:copy)
      log_step('copy', source_path, target_path, chmod: (chmod ? sprintf('%o', chmod) : false))

      Instructions::Copy.new(chain, source_path, target_path).tap do |ins|
        ins.chmod      = chmod if chmod
        ins.ignorefile = ignorefile
        ins.no_cache   = no_cache
        ins.recursive  = recursive

        ins.run!
      end

      self
    end

    # Destroy the images and containers created, and attempt to return the docker
    # state as it was before the project. The project object itself cannot be reused
    # after it is destroyed.
    #
    # @api private
    def destroy!(force: false)
      return self if frozen?
      finalize!(force: force)
      chain.destroy!(force: force) if chain
      freeze
    end

    # Meta instruction to signal to the builder that the build is done.
    #
    # @api private
    def done!
      throw :done
    end

    # Download (and cache) a file from `source_url`, and copy it into the
    # `target_path` in the container with a specific `chmod` (defaults to 0644).
    #
    # The cache currently cannot be disabled.
    def download_once(source_url, target_path, chmod: 0644)
      requires_from!(:download_once)

      unless cache.key?(source_url)
        cache.set(source_url) do |obj|
          chunked = proc do |chunk, _remaining_bytes, _total_bytes|
            obj.write(chunk)
          end
          Excon.get(source_url, response_block: chunked)
        end
      end

      log_step('download_once', source_url, target_path, chmod: sprintf('%o', chmod))

      # TODO(rpasay): invalidate cache when the downloaded file changes,
      # and then force rebuild
      digest = Digest::MD5.hexdigest(source_url)
      chain.run("# DOWNLOAD file:md5:#{digest} #{target_path}") do |container|
        container.archive_put do |output|
          TarWriter.new(output) do |tar|
            cache.get(source_url) do |input|
              tar.add_file(target_path, chmod) do |tar_file|
                tar_file.write(input.read)
              end
            end
          end
        end
      end

      self
    end

    # **This instruction is *optional*, but if specified, must appear at the
    # beginning of the file.**
    #
    # This instruction is used to restrict the version of `drydock` required to
    # run the `Drydockfile`. When not specified, any version of `drydock` is
    # allowed to run the file.
    #
    # The version specifier understands any bundler-compatible (and therefore
    # [gem-compatible](http://guides.rubygems.org/patterns/#semantic-versioning))
    # version specification; it even understands the twiddle-waka (`~>`) operator.
    #
    # @example
    #   drydock '~> 0.5'
    # @param [String] version The version specification to use.
    def drydock(version = '>= 0')
      fail InvalidInstructionError, '`drydock` must be called before `from`' if chain
      log_step('drydock', version)

      requirement = Gem::Requirement.create(version)
      current     = Gem::Version.create(Drydock.version)

      unless requirement.satisfied_by?(current)
        fail InsufficientVersionError, "build requires #{version.inspect}, but you're on #{Drydock.version.inspect}"
      end

      self
    end

    # Sets the entrypoint command for an image.
    #
    # {#entrypoint} corresponds to the `ENTRYPOINT` Dockerfile instruction. This
    # instruction does **not** run the command, but rather provides the default
    # command to be run when the image is run without specifying a command.
    #
    # As with the {#cmd} instruction, {#entrypoint} has three forms, of which the
    # first two forms are preferred over the last one.
    #
    # @param (see #cmd)
    def entrypoint(command)
      requires_from!(:entrypoint)
      log_step('entrypoint', command)

      command = ['/bin/sh', '-c', command.to_s] unless command.is_a?(Array)
      chain.run("# ENTRYPOINT #{command.inspect}", entrypoint: command)
      self
    end

    # Set an environment variable, which will be persisted in future images
    # (unless it is specifically overwritten) and derived projects.
    #
    # Subsequent commands can refer to the environment variable by preceeding
    # the variable with a `$` sign, e.g.:
    #
    # ```
    #   env 'APP_ROOT', '/app'
    #   mkdir '$APP_ROOT'
    #   run ['some-command', '--install-into=$APP_ROOT']
    # ```
    #
    # Multiple calls to this instruction will build on top of one another.
    # That is, after the following two instructions:
    #
    # ```
    #   env 'APP_ROOT',   '/app'
    #   env 'BUILD_ROOT', '/build'
    # ```
    #
    # the resulting image will have both `APP_ROOT` and `BUILD_ROOT` set. Later
    # instructions overwrites previous instructions of the same name:
    #
    # ```
    #   # 1
    #   env 'APP_ROOT', '/app'
    #   # 2
    #   env 'APP_ROOT', '/home/jdoe/app'
    #   # 3
    # ```
    #
    # At `#1`, `APP_ROOT` is not set (assuming no other instruction comes before
    # it). At `#2`, `APP_ROOT` is set to '/app'. At `#3`, `APP_ROOT` is set to
    # `/home/jdoe/app`, and its previous value is no longer available.
    #
    # Note that the environment variable is not evaluated in ruby; in fact, the
    # `$` sign should be passed as-is to the instruction. As with shell
    # programming, the variable name should **not** be preceeded by the `$`
    # sign when declared, but **must be** when referenced.
    #
    # @param [String] name The name of the environment variable. By convention,
    #   the name should be uppercased and underscored. The name should **not**
    #   be preceeded by a `$` sign in this context.
    # @param [String] value The value of the variable. No extra quoting should be
    #   necessary here.
    def env(name, value)
      requires_from!(:env)
      log_step('env', name, value)
      chain.run("# SET ENV #{name}", env: ["#{name}=#{value}"])
      self
    end

    # Set multiple environment variables at once. The values will be persisted in
    # future images and derived projects, unless specifically overwritten.
    #
    # The following instruction:
    #
    # ```
    #   envs APP_ROOT: '/app', BUILD_ROOT: '/tmp/build'
    # ```
    #
    # is equivalent to the more verbose:
    #
    # ```
    #   env 'APP_ROOT', '/app'
    #   env 'BUILD_ROOT', '/tmp/build'
    # ```
    #
    # When the same key appears more than once in the same {#envs} instruction,
    # the same rules for ruby hashes are used, which most likely (but not guaranteed
    # between ruby version) means the last value set is used.
    #
    # See also notes for {#env}.
    #
    # @param [Hash, #map] pairs A hash-like enumerable, where `#map` yields exactly
    #   two elements. See {#env} for any restrictions of the name (key) and value.
    def envs(pairs)
      requires_from!(:envs)
      log_step('envs', pairs)

      values = pairs.map { |name, value| "#{name}=#{value}" }
      chain.run("# SET ENVS #{pairs.inspect}", env: values)
      self
    end

    # Expose one or more ports. The values will be persisted in future images
    #
    # When `ports` is specified, the format must be: ##/type where ## is the port
    # number and type is either tcp or udp. For example, "80/tcp", "53/udp".
    #
    # Otherwise, when the `tcp` or `udp` options are specified, only the port
    # numbers are required.
    #
    # @example Different ways of exposing port 53 UDP and ports 80 and 443 TCP:
    #   expose '53/udp', '80/tcp', '443/tcp'
    #   expose udp: 53, tcp: [80, 443]
    # @param [Array<String>] ports An array of strings of port specifications.
    #   Each port specification must look like `#/type`, where `#` is the port
    #   number, and `type` is either `udp` or `tcp`.
    # @param [Integer, Array<Integer>] tcp A TCP port number to open, or an array
    #   of TCP port numbers to open.
    # @param [Integer, Array<Integer>] udp A UDP port number to open, or an array
    #   of UDP port numbers to open.
    def expose(*ports, tcp: [], udp: [])
      requires_from!(:expose)

      Array(tcp).flatten.each { |p| ports << "#{p}/tcp" }
      Array(udp).flatten.each { |p| ports << "#{p}/udp" }

      log_step('expose', *ports)

      chain.run("# SET PORTS #{ports.inspect}", expose: ports)
    end

    # Build on top of the `from` image. **This must be the first instruction of
    # the project,** although non-instructions may appear before this.
    #
    # If the `drydock` instruction is provided, `from` should come after it.
    #
    # @param [#to_s] repo The name of the repository, which may be any valid docker
    #   repository name, and may optionally include the registry address, e.g.,
    #   `johndoe/thing` or `quay.io/jane/app`. The name *must not* contain the tag name.
    # @param [#to_s] tag The tag to use.
    def from(repo, tag = 'latest')
      fail InvalidInstructionError, '`from` must only be called once per project' if chain

      repo = repo.to_s
      tag  = tag.to_s

      log_step('from', repo, tag)
      @chain = PhaseChain.from_repo(repo, tag)
      self
    end

    # Finalize everything. This will be automatically invoked at the end of
    # the build, and should not be called manually.
    #
    # No further changes to the object is possible after finalization.
    #
    # @api private
    def finalize!(force: false)
      return self if finalized?

      if chain
        chain.finalize!(force: force)
      end

      if stream_monitor
        stream_monitor.kill
        stream_monitor.join
      end

      @finalized = true
      self
    end

    # Derive a new project based on the current state of the current project.
    # This instruction returns the new project that can be referred to elsewhere,
    # and most useful when combined with other inter-project instructions,
    # such as {#import}.
    #
    # For example:
    #
    # ```
    #   from 'some-base-image'
    #
    #   APP_ROOT = '/app'
    #   mkdir APP_ROOT
    #
    #   # 1:
    #   ruby_build = derive {
    #     copy 'Gemfile', APP_ROOT
    #     run 'bundle install --path vendor'
    #   }
    #
    #   # 2:
    #   js_build = derive {
    #     copy 'package.json', APP_ROOT
    #     run 'npm install'
    #   }
    #
    #   # 3:
    #   derive {
    #     import APP_ROOT, from: ruby_build
    #     import APP_ROOT, from: js_build
    #     tag 'jdoe/app', 'latest', force: true
    #   }
    # ```
    #
    # In the example above, an image is created with a new directory `/app`.
    # From there, the build branches out into three directions:
    #
    # 1. Create a new project referred to as `ruby_build`. The result of this
    #    project is an image with `/app`, a `Gemfile` in it, and a `vendor`
    #    directory containing vendored gems.
    # 2. Create a new project referred to as `js_build`. The result of this
    #    project is an image with `/app`, a `package.json` in it, and a
    #    `node_modules` directory containing vendored node.js modules.
    #    This project does **not** contain any of the contents of `ruby_build`.
    # 3. Create an anonymous project containing only the empty `/app` directory.
    #    Onto that, we'll import the contents of `/app` from `ruby_build` into
    #    this anonymous project. We'll do the same with the contents of `/app`
    #    from `js_build`. Finally, the resulting image is given the tag
    #    `jdoe/app:latest`.
    #
    # Because each derived project lives on its own and only depends on the
    # root project (whose end state is essentially the {#mkdir} instruction),
    # when `Gemfile` changes but `package.json` does not, only the first
    # derived project will be rebuilt (and following that, the third as well).
    #
    # @param (see #initialize)
    # @option (see #initialize)
    def derive(opts = {}, &blk)
      clean_opts  = build_opts.delete_if { |_, v| v.nil? }
      derive_opts = clean_opts.merge(opts).merge(chain: chain)

      Project.new(derive_opts).tap do |project|
        project.instance_eval(&blk) if blk
      end
    end

    def finalized?
      @finalized
    end

    # Access to the logger object.
    #
    # @return [Logger] A logger object on which one could call `#info`, `#error`,
    #   and the likes.
    def logger
      Drydock.logger
    end

    # Import a `path` from a different project. The `from` option should be
    # project, usually the result of a `derive` instruction.
    #
    # @todo Add a #load method as an alternative to #import
    #   Doing so would allow importing a full container, including things from
    #   /etc, some of which may be mounted from the host.
    #
    # @todo Do not always append /. to the #archive_get calls
    #   We must check the type of `path` inside the container first.
    #
    # @todo Break this large method into smaller ones.
    def import(path, from: nil, force: false, spool: false)
      mkdir(path)

      requires_from!(:import)
      fail InvalidInstructionError, 'cannot `import` from `/`' if path == '/' && !force
      fail InvalidInstructionError, '`import` requires a `from:` option' if from.nil?
      log_step('import', path, from: from.last_image.id)

      Instructions::Import.new(from.chain, chain, path).tap do |ins|
        ins.force = force
        ins.spool = spool

        ins.run!

        log_info("Imported #{Formatters.number(ins.total_size)} bytes")
      end

      self
    end

    # Retrieve the last image object built in this project.
    #
    # If no image has been built, returns `nil`.
    def last_image
      chain ? chain.last_image : nil
    end

    # Create a new directory specified by `path` in the image.
    #
    # @param [String] path The path to create inside the image.
    # @param [String] chmod The mode to which the new directory will be chmodded.
    #   If not specified, the default umask is used to determine the mode.
    def mkdir(path, chmod: nil)
      if chmod
        run "mkdir -p #{path} && chmod #{chmod} #{path}"
      else
        run "mkdir -p #{path}"
      end
    end

    # **NOT SUPPORTED YET**
    #
    # @todo on_build instructions should be deferred to the end.
    def on_build(instruction = nil, &_blk)
      fail NotImplementedError, 'on_build is not yet supported'

      requires_from!(:on_build)
      log_step('on_build', instruction)
      chain.run("# ON_BUILD #{instruction}", on_build: instruction)
      self
    end

    # This instruction is used to run the command `cmd` against the current
    # project. The `opts` may be one of:
    #
    # * `no_commit`, when true, the container will not be committed to a
    #   new image. Most of the time, you want this to be false (default).
    # * `no_cache`, when true, the container will be rebuilt every time.
    #   Most of the time, you want this to be false (default). When
    #   `no_commit` is true, this option is automatically set to true.
    # * `env`, which can be used to specify a set of environment variables.
    #   For normal usage, you should use the `env` or `envs` instructions.
    # * `expose`, which can be used to specify a set of ports to expose. For
    #   normal usage, you should use the `expose` instruction instead.
    # * `on_build`, which can be used to specify low-level on-build options. For
    #   normal usage, you should use the `on_build` instruction instead.
    #
    # Additional `opts` are also recognized:
    #
    # * `author`, a string, preferably in the format of "Name <email@domain.com>".
    #   If provided, this overrides the author name set with {#author}.
    # * `comment`, an arbitrary string used as a comment for the resulting image
    #
    # If `run` results in a container being created and `&blk` is provided, the
    # container will be yielded to the block.
    def run(cmd, opts = {}, &blk)
      requires_from!(:run)

      cmd = build_cmd(cmd)

      run_opts = opts.dup
      run_opts[:author]  = opts[:author] || build_opts[:author]
      run_opts[:comment] = opts[:comment] || build_opts[:comment]

      log_step('run', cmd, run_opts)
      chain.run(cmd, run_opts, &blk)
      self
    end

    # Set project options.
    def set(key, value = nil, &blk)
      key = key.to_sym
      fail ArgumentError, "unknown option #{key.inspect}" unless build_opts.key?(key)
      fail ArgumentError, 'one of value or block is required' if value.nil? && blk.nil?
      fail ArgumentError, 'only one of value or block may be provided' if value && blk

      build_opts[key] = value || blk
    end

    # Tag the current state of the project with a repo and tag.
    #
    # When `force` is false (default), this instruction will raise an error if
    # the tag already exists. When true, the tag will be overwritten without
    # any warnings.
    def tag(repo, tag = 'latest', force: false)
      requires_from!(:tag)
      log_step('tag', repo, tag, force: force)

      chain.tag(repo, tag, force: force)
      self
    end

    # Use a `plugin` to issue other commands. The block form can be used to issue
    # multiple commands:
    #
    # ```
    #   with Plugins::APK do |apk|
    #     apk.update
    #   end
    # ```
    #
    # In cases of single commands, the above is the same as:
    #
    # ```
    #   with(Plugins::APK).update
    # ```
    def with(plugin, &blk)
      (@plugins[plugin] ||= plugin.new(self)).tap do |instance|
        blk.call(instance) if blk
      end
    end

    private

    attr_reader :build_opts, :stream_monitor

    def build_cmd(cmd)
      if @run_path.empty?
        cmd.to_s.strip
      else
        "cd #{@run_path.join('/')} && #{cmd}".strip
      end
    end

    def cache
      build_opts[:cache] ||= ObjectCaches::NoCache.new
    end

    def ignorefile
      @ignorefile ||= IgnorefileDefinition.new(build_opts[:ignorefile])
    end

    def log_info(msg, indent: 0)
      Drydock.logger.info(indent: indent, message: msg)
    end

    def log_step(op, *args)
      opts   = args.last.is_a?(Hash) ? args.pop : {}
      optstr = opts.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')

      argstr = args.map(&:inspect).join(', ')

      Drydock.logger.info("##{chain ? chain.serial : 0}: #{op}(#{argstr}#{optstr.empty? ? '' : ", #{optstr}"})")
    end

    def requires_from!(instruction)
      fail InvalidInstructionError, "`#{instruction}` cannot be called before `from`" unless chain
    end

  end
end