zuazo/dockerspec

View on GitHub
lib/dockerspec/builder.rb

Summary

Maintainability
A
25 mins
Test Coverage
# encoding: UTF-8
#
# Author:: Xabier de Zuazo (<xabier@zuazo.org>)
# Copyright:: Copyright (c) 2015 Xabier de Zuazo
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require 'rspec'
require 'rspec/its'
require 'rspec/retry'
require 'tmpdir'
require 'erubis'
require 'dockerspec/docker_gem'
require 'dockerspec/builder/config_helpers'
require 'dockerspec/builder/matchers'
require 'dockerspec/builder/logger'
require 'dockerspec/builder/image_gc'
require 'dockerspec/helper/ci'
require 'dockerspec/helper/multiple_sources_description'
require 'dockerspec/docker_exception_parser'

module Dockerspec
  #
  # A class to build a container image.
  #
  class Builder
    include Dockerspec::Builder::ConfigHelpers
    include Dockerspec::Helper::CI
    include Dockerspec::Helper::MultipleSourcesDescription

    #
    # Constructs a Docker image builder class.
    #
    # @example Build an Image From CWD or `DOCKERFILE_PATH`
    #   Dockerspec::Builder.new #=> #<Dockerspec::Builder:0x0123>
    #
    # @example Build an Image from a Directory
    #   Dockerspec::Builder.new('imagedir') #=> #<Dockerspec::Builder:0x0124>
    #
    # @example Do Not Remove the Image
    #   Dockerspec::Builder.new('../', rm: false)
    #     #=> #<Dockerspec::Builder:0x0125>
    #
    # @example Passing Multiple Params
    #   Dockerspec::Builder.new(path: '../', tag: 'myapp', rm: false)
    #     #=> #<Dockerspec::Builder:0x0125>
    #
    # @param opts [String, Hash] The `:path` or a list of options.
    #
    # @option opts [String] :path ('.') The directory or file that contains the
    #   *Dockerfile*. By default tries to read it from the `DOCKERFILE_PATH`
    #   environment variable and uses `'.'` if it is not set.
    # @option opts [String] :string Use this string as *Dockerfile* instead of
    #   `:path`. Not set by default.
    # @option opts [String] :string_build_path Used to specify the path that is
    #    used for the docker build of a string *Dockerfile*. This enables ADD
    #    statements in the Dockerfile to access files that are outside of .
    #    Not set by default and overrides the default '.'
    # @option opts [String] :template Use this [Erubis]
    #   (http://www.kuwata-lab.com/erubis/users-guide.html) template file as
    #   *Dockerfile*.
    # @option opts [String] :id Use this Docker image ID instead of a
    #   *Dockerfile*.
    # @option opts [Boolean] :rm Whether to remove the generated docker images
    #   after running the tests. By default only removes them if it is running
    #   on a CI machine.
    # @option opts [Hash, Erubis::Context] :context ({}) Template *context*
    #   used when the `:template` source is used.
    # @option opts [String] :tag Repository tag to be applied to the resulting
    #   image.
    # @option opts [Integer, Symbol] :log_level Sets the docker library
    #   verbosity level. Possible values:
    #    `:silent` or `0` (no output),
    #    `:ci` or `1` (enables some outputs recommended for CI environments),
    #    `:info` or `2` (gives information about main build steps),
    #    `:debug` or `3` (outputs all the provided information in its raw
    #      original form).
    #
    # @see Dockerspec::RSpec::Resources#docker_build
    #
    # @api public
    #
    def initialize(*opts)
      @image = nil
      @options = parse_options(opts)
    end

    #
    # Returns Docker image ID.
    #
    # @example Get the Image ID After Building the Image
    #   d = Dockerspec::Builder.new
    #   d.build
    #   d.id #=> "9f8866b49bfb[...]"
    #
    # @return [String] Docker image ID.
    #
    # @api public
    #
    def id
      @image.id
    end

    #
    # Builds the docker image.
    #
    # @example Build an Image From a Path
    #   d = Dockerspec::Builder.new(path: 'dockerfile_dir')
    #   d.build #=> #<Dockerspec::Builder:0x0125>
    #
    # @return [String] Docker image ID.
    #
    # @raise [Dockerspec::DockerError] For underlaying docker errors.
    #
    # @api public
    #
    def build
      send("build_from_#{source}", @options[source])
      self
    end

    #
    # Gets a descriptions of the object.
    #
    # @example
    #   d = Dockerspec::Builder.new('.')
    #   d.to_s #=> "Docker Build from path: ."
    #
    # @return [String] The object description.
    #
    # @api public
    #
    def to_s
      description('Docker Build from')
    end

    protected

    #
    # Gets the source to generate the image from.
    #
    # Possible values: `:string`, `:template`, `:id`, `:path`.
    #
    # @example Building an Image from a Path
    #   self.source #=> :path
    #
    # @example Building an Image from a Template
    #   self.source #=> :template
    #
    # @return [Symbol] The source.
    #
    # @api private
    #
    def source
      return @source unless @source.nil?
      @source = %i(string template id path).find { |from| @options.key?(from) }
    end

    #
    # Generates a description when build from a template.
    #
    # @example
    #   self.description_from_template("file.erb") #=> "file.erb"
    #
    # @return [String] A description.
    #
    # @see Dockerspec::Helper::MultipleSourcesDescription#description_from_file
    #
    # @api private
    #
    alias description_from_template description_from_file

    #
    # Sets or gets the Docker image.
    #
    # @param img [Docker::Image] The Docker image to set.
    #
    # @return [Docker::Image] The Docker image object.
    #
    # @api private
    #
    def image(img = nil)
      return @image if img.nil?
      ImageGC.instance.add(img.id) if @options[:rm]
      @image = img
    end

    #
    # Gets the default options configured using `RSpec.configuration`.
    #
    # @example
    #   self.rspec_options #=> {:path=>".", :rm=>true, :log_level=>:silent}
    #
    # @return [Hash] The configuration options.
    #
    # @api private
    #
    def rspec_options
      config = ::RSpec.configuration
      {}.tap do |opts|
        opts[:path] = config.dockerfile_path if config.dockerfile_path?
        opts[:rm] = config.rm_build if config.rm_build?
        opts[:log_level] = config.log_level if config.log_level?
      end
    end

    #
    # Gets the default configuration options after merging them with RSpec
    # configuration options.
    #
    # @example
    #   self.default_options #=> {:path=>".", :rm=>true, :log_level=>:silent}
    #
    # @return [Hash] The configuration options.
    #
    # @api private
    #
    def default_options
      {
        path: ENV['DOCKERFILE_PATH'] || '.',
        # Autoremove images in all CIs except Travis (not supported):
        rm: ci? && !travis_ci?,
        # Avoid CI timeout errors:
        log_level: ci? ? :ci : :silent
      }.merge(rspec_options)
    end

    #
    # Parses the configuration options passed to the constructor.
    #
    # @example
    #   self.parse_options #=> {:path=>".", :rm=>true, :log_level=>:silent}
    #
    # @param opts [Array<String, Hash>] The list of optitag. The strings will
    #   be interpreted as `:path`, others will be merged.
    #
    # @return [Hash] The configuration options.
    #
    # @see #initialize
    #
    # @api private
    #
    def parse_options(opts)
      opts_hs_ary = opts.map { |x| x.is_a?(Hash) ? x : { path: x } }
      opts_hs_ary.reduce(default_options) { |a, e| a.merge(e) }
    end

    #
    # Generates the Ruby block used to parse the logs during image construction.
    #
    # @return [Proc] The Ruby block.
    #
    # @api private
    #
    def build_block
      proc { |chunk| logger.print_chunk(chunk) }
    end

    #
    # Builds the image from a string. Generates the Docker tag if required.
    #
    # It also saves the generated image in the object internally.
    #
    # This creates a temporary directory where it copies all the files and
    # generates the temporary Dockerfile.
    #
    # @param string [String] The Dockerfile content.
    # @param dir [String] The directory to copy the files from. Files that are
    #   required by the Dockerfile passed in *string*. If not passed, then
    #   the 'string_build_path' option is used. If that is not used, '.' is
    #   assumed.
    #
    # @return void
    #
    # @api private
    #
    def build_from_string(string, dir = '.')
      dir = @options[:string_build_path] if @options[:string_build_path]
      Dir.mktmpdir do |tmpdir|
        FileUtils.cp_r("#{dir}/.", tmpdir)
        dockerfile = File.join(tmpdir, 'Dockerfile')
        File.open(dockerfile, 'w') { |f| f.write(string) }
        build_from_dir(tmpdir)
      end
    end

    #
    # Builds the image from a file that is not called *Dockerfile*.
    #
    # It also saves the generated image in the object internally.
    #
    # This creates a temporary directory where it copies all the files and
    # generates the temporary Dockerfile.
    #
    # @param file [String] The Dockerfile file path.
    #
    # @return void
    #
    # @api private
    #
    def build_from_file(file)
      dir = File.dirname(file)
      string = IO.read(file)
      build_from_string(string, dir)
    end

    #
    # Builds the image from a directory with a Dockerfile.
    #
    # It also saves the generated image in the object internally.
    #
    # @param dir [String] The directory path.
    #
    # @return void
    #
    # @raise [Dockerspec::DockerError] For underlaying docker errors.
    #
    # @api private
    #
    def build_from_dir(dir)
      image(::Docker::Image.build_from_dir(dir, &build_block))
      add_repository_tag
    rescue ::Docker::Error::DockerError => e
      DockerExceptionParser.new(e)
    end

    #
    # Builds the image from a directory or a file.
    #
    # It also saves the generated image in the object internally.
    #
    # @param path [String] The path.
    #
    # @return void
    #
    # @api private
    #
    def build_from_path(path)
      if !File.directory?(path) && File.basename(path) == 'Dockerfile'
        path = File.dirname(path)
      end
      File.directory?(path) ? build_from_dir(path) : build_from_file(path)
    end

    #
    # Builds the image from a template.
    #
    # It also saves the generated image in the object internally.
    #
    # @param file [String] The Dockerfile [Erubis]
    # (http://www.kuwata-lab.com/erubis/users-guide.html) template path.
    #
    # @return void
    #
    # @api private
    #
    def build_from_template(file)
      context = @options[:context] || {}

      template = IO.read(file)
      eruby = Erubis::Eruby.new(template)
      string = eruby.evaluate(context)
      build_from_string(string, File.dirname(file))
    end

    #
    # Gets the image from a Image ID.
    #
    # It also saves the image in the object internally.
    #
    # @param id [String] The Docker image ID.
    #
    # @return void
    #
    # @raise [Dockerspec::DockerError] For underlaying docker errors.
    #
    # @api private
    #
    def build_from_id(id)
      @image = ::Docker::Image.get(id)
      add_repository_tag
    rescue ::Docker::Error::NotFoundError
      @image = ::Docker::Image.create('fromImage' => id)
      add_repository_tag
    rescue ::Docker::Error::DockerError => e
      DockerExceptionParser.new(e)
    end

    #
    # Adds a repository name and a tag to the Docker image.
    #
    # @return void
    #
    # @api private
    #
    def add_repository_tag
      return unless @options.key?(:tag)
      repo, repo_tag = @options[:tag].split(':', 2)
      @image.tag(repo: repo, tag: repo_tag, force: true)
    end

    #
    # Gets the Docker Logger to use during the build process.
    #
    # @return void
    #
    # @api private
    #
    def logger
      @logger ||= Logger.instance(@options[:log_level])
    end
  end
end