class Drydock::Project

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.

Constants

DEFAULT_OPTIONS

Attributes

build_opts[R]
chain[R]
stream_monitor[R]

Public Class Methods

new(build_opts = {}) click to toggle source

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.

# File lib/drydock/project.rb, line 32
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

Public Instance Methods

author(name: nil, email: nil) click to toggle source

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

# File lib/drydock/project.rb, line 58
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
build_id() click to toggle source

Retrieve the current build ID for this project. If no image has been built, returns the string ‘0’.

# File lib/drydock/project.rb, line 69
def build_id
  chain ? chain.serial : '0'
end
cd(path = '/', &blk) click to toggle source

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

# File lib/drydock/project.rb, line 77
def cd(path = '/', &blk)
  @run_path << path
  blk.call if blk
ensure
  @run_path.pop
end
cmd(command) click to toggle source

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.
# File lib/drydock/project.rb, line 107
def cmd(command)
  requires_from!(:cmd)
  log_step('cmd', command)

  unless command.is_a?(Array)
    command = ['/bin/sh', '-c', command.to_s]
  end

  chain.run("# CMD #{command.inspect}", command: command)
  self
end
copy(source_path, target_path, chmod: false, no_cache: false, recursive: true) click to toggle source

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!)

# File lib/drydock/project.rb, line 143
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
derive(opts = {}, &blk) click to toggle source

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)

# File lib/drydock/project.rb, line 476
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
destroy!(force: false) click to toggle source

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

# File lib/drydock/project.rb, line 164
def destroy!(force: false)
  return self if frozen?
  finalize!(force: force)
  chain.destroy!(force: force) if chain
  freeze
end
done!() click to toggle source

Meta instruction to signal to the builder that the build is done.

@api private

# File lib/drydock/project.rb, line 174
def done!
  throw :done
end
download_once(source_url, target_path, chmod: 0644) click to toggle source

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.

# File lib/drydock/project.rb, line 182
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
drydock(version = '>= 0') click to toggle source

**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](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.

# File lib/drydock/project.rb, line 228
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
entrypoint(command) click to toggle source

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)

# File lib/drydock/project.rb, line 252
def entrypoint(command)
  requires_from!(:entrypoint)
  log_step('entrypoint', command)

  unless command.is_a?(Array)
    command = ['/bin/sh', '-c', command.to_s]
  end

  chain.run("# ENTRYPOINT #{command.inspect}", entrypoint: command)
  self
end
env(name, value) click to toggle source

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.
# File lib/drydock/project.rb, line 309
def env(name, value)
  requires_from!(:env)
  log_step('env', name, value)
  chain.run("# SET ENV #{name}", env: ["#{name}=#{value}"])
  self
end
envs(pairs) click to toggle source

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.
# File lib/drydock/project.rb, line 340
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(*ports, tcp: [], udp: []) click to toggle source

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.
# File lib/drydock/project.rb, line 367
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
finalize!(force: false) click to toggle source

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

# File lib/drydock/project.rb, line 404
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
finalized?() click to toggle source
# File lib/drydock/project.rb, line 485
def finalized?
  @finalized
end
from(repo, tag = 'latest') click to toggle source

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.

# File lib/drydock/project.rb, line 387
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
import(path, from: nil, force: false, spool: false) click to toggle source

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.

# File lib/drydock/project.rb, line 508
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)

  total_size = 0

  if spool
    spool_file = Tempfile.new('drydock')
    log_info("Spooling to #{spool_file.path}")

    from.send(:chain).run("# EXPORT #{path}", no_commit: true) do |source_container|
      source_container.archive_get(path + "/.") do |chunk|
        spool_file.write(chunk.to_s).tap { |b| total_size += b }
      end
    end

    spool_file.rewind
    chain.run("# IMPORT #{path}", no_cache: true) do |target_container|
      target_container.archive_put(path) do |output|
        output.write(spool_file.read)
      end
    end

    spool_file.close
  else
    chain.run("# IMPORT #{path}", no_cache: true) do |target_container|
      target_container.archive_put(path) do |output|
        from.send(:chain).run("# EXPORT #{path}", no_commit: true) do |source_container|
          source_container.archive_get(path + "/.") do |chunk|
            output.write(chunk.to_s).tap { |b| total_size += b }
          end
        end
      end
    end
  end

  log_info("Imported #{Formatters.number(total_size)} bytes")
end
last_image() click to toggle source

Retrieve the last image object built in this project.

If no image has been built, returns ‘nil`.

# File lib/drydock/project.rb, line 554
def last_image
  chain ? chain.last_image : nil
end
logger() click to toggle source

Access to the logger object.

@return [Logger] A logger object on which one could call ‘#info`, `#error`,

and the likes.
# File lib/drydock/project.rb, line 493
def logger
  Drydock.logger
end
mkdir(path, chmod: nil) click to toggle source

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.
# File lib/drydock/project.rb, line 563
def mkdir(path, chmod: nil)
  if chmod
    run "mkdir -p #{path} && chmod #{chmod} #{path}"
  else
    run "mkdir -p #{path}"
  end
end
on_build(instruction = nil, &_blk) click to toggle source

**NOT SUPPORTED YET**

@todo on_build instructions should be deferred to the end.

# File lib/drydock/project.rb, line 574
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
run(cmd, opts = {}, &blk) click to toggle source

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.

# File lib/drydock/project.rb, line 606
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(key, value = nil, &blk) click to toggle source

Set project options.

# File lib/drydock/project.rb, line 621
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(repo, tag = 'latest', force: false) click to toggle source

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.

# File lib/drydock/project.rb, line 635
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
with(plugin, &blk) click to toggle source

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

“‘

# File lib/drydock/project.rb, line 657
def with(plugin, &blk)
  (@plugins[plugin] ||= plugin.new(self)).tap do |instance|
    blk.call(instance) if blk
  end
end

Private Instance Methods

build_cmd(cmd) click to toggle source
# File lib/drydock/project.rb, line 667
def build_cmd(cmd)
  if @run_path.empty?
    cmd.to_s.strip
  else
    "cd #{@run_path.join('/')} && #{cmd}".strip
  end
end
cache() click to toggle source
# File lib/drydock/project.rb, line 675
def cache
  build_opts[:cache] ||= ObjectCaches::NoCache.new
end
ignorefile() click to toggle source
# File lib/drydock/project.rb, line 679
def ignorefile
  @ignorefile ||= IgnorefileDefinition.new(build_opts[:ignorefile])
end
log_info(msg, indent: 0) click to toggle source
# File lib/drydock/project.rb, line 683
def log_info(msg, indent: 0)
  Drydock.logger.info(indent: indent, message: msg)
end
log_step(op, *args) click to toggle source
# File lib/drydock/project.rb, line 687
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
requires_from!(instruction) click to toggle source
# File lib/drydock/project.rb, line 696
def requires_from!(instruction)
  fail InvalidInstructionError, "`#{instruction}` cannot be called before `from`" unless chain
end