Module: Kernel

Defined in:
lib/donce.rb

Overview

Execute Docker container and clean up afterwards.

This function helps building temporary Docker images, run Docker containers, and clean up afterwards — may be convenient for automated tests (for example, with Minitest):

class MyTest < Minitest::Test
  def test_prints_hello_world
    stdout = donce(
      dockerfile: '
        FROM ubuntu
        CMD echo "Hello, world!"
      '
    )
    assert_equal("Hello, world!\n", stdout)
  end
end

It’s possible to pass a block to it too, which will lead to background execution of the container (in daemon mode):

def test_runs_daemon
  donce(dockerfile: "FROM ubuntu\nCMD sleep 9999") do |id|
    refute_empty(id)  # the ID of the container
  end
end

If you need to run docker via sudo, simply set DONCE_SUDO environment variable to any value.

Author

Yegor Bugayenko ([email protected])

Copyright

Copyright © 2024-2025 Yegor Bugayenko

License

MIT

Instance Method Summary collapse

Instance Method Details

#donce(dockerfile: nil, image: nil, home: nil, log: $stdout, args: '', env: {}, root: false, command: '', timeout: 60, volumes: {}, ports: {}, build_args: {}) ⇒ String

Build Docker image (or use existing one), run Docker container, and then clean up.

Parameters:

  • dockerfile (String) (defaults to: nil)

    The content of the Dockerfile (if array is provided, it will be concatenated)

  • home (String) (defaults to: nil)

    The directory with Dockerfile and all other necessary files

  • image (String) (defaults to: nil)

    The name of Docker image, e.g. “ubuntu:22.04”

  • log (Logger) (defaults to: $stdout)

    The logging destination, can be $stdout

  • args (String|Array<String>) (defaults to: '')

    List of extra arguments for the docker command

  • env (Hash<String,String>) (defaults to: {})

    Environment variables going into the container

  • volumes (Hash<String,String>) (defaults to: {})

    Local to container volumes mapping

  • ports (Hash<String,String>) (defaults to: {})

    Local to container port mapping

  • build_args (Hash<String,String>) (defaults to: {})

    Arguments for docker build as --build-arg may need

  • root (Boolean) (defaults to: false)

    Let user inside the container be “root”?

  • command (String|Array<String>) (defaults to: '')

    The command for the script inside the container

  • timeout (Integer) (defaults to: 60)

    Maximum seconds to spend on each docker call

Returns:

  • (String)

    The stdout of the container



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/donce.rb', line 69

def donce(dockerfile: nil, image: nil, home: nil, log: $stdout, args: '', env: {}, root: false, command: '',
          timeout: 60, volumes: {}, ports: {}, build_args: {})
  raise 'Either use "dockerfile" or "home"' if dockerfile && home
  raise 'Either use "dockerfile" or "image"' if dockerfile && image
  raise 'Either use "image" or "home"' if home && image
  raise 'Either "dockerfile", or "home", or "image" must be provided' if !dockerfile && !home && !image
  raise 'The "timeout" must be an integer or nil' unless timeout.nil? || timeout.is_a?(Integer)
  raise 'The "volumes" is nil' if volumes.nil?
  raise 'The "volumes" must be a Hash' unless volumes.is_a?(Hash)
  raise 'The "log" is nil' if log.nil?
  raise 'The "args" is nil' if args.nil?
  raise 'The "args" must be a String' unless args.is_a?(String)
  raise 'The "env" is nil' if env.nil?
  raise 'The "env" must be a Hash' unless env.is_a?(Hash)
  raise 'The "command" is nil' if command.nil?
  raise 'The "command" must be a String or an Array' unless command.is_a?(String) || command.is_a?(Array)
  raise 'The "timeout" is nil' if timeout.nil?
  raise 'The "timeout" must be a number' unless timeout.is_a?(Integer) || timeout.is_a?(Float)
  raise 'The "ports" is nil' if ports.nil?
  raise 'The "ports" must be a Hash' unless ports.is_a?(Hash)
  raise 'The "build_args" is nil' if build_args.nil?
  raise 'The "build_args" must be a Hash' unless build_args.is_a?(Hash)
  docker = ENV['DONCE_SUDO'] ? 'sudo docker' : 'docker'
  command = command.join(' ') if command.is_a?(Array)
  img =
    if image
      image
    else
      i = "donce-#{SecureRandom.hex(6)}"
      a = [
        "--tag #{i}",
        build_args.map { |k, v| "--build-arg #{Shellwords.escape("#{k}=#{v}")}" }.join(' ')
      ].compact.join(' ')
      if dockerfile
        Dir.mktmpdir do |tmp|
          dockerfile = dockerfile.join("\n") if dockerfile.is_a?(Array)
          File.write(File.join(tmp, 'Dockerfile'), dockerfile)
          qbash("#{docker} build #{a} #{Shellwords.escape(tmp)}", log:)
        end
      elsif home
        qbash("#{docker} build #{a} #{Shellwords.escape(home)}", log:)
      else
        raise 'Either "dockerfile" or "home" must be provided'
      end
      i
    end
  container = "donce-#{SecureRandom.hex(6)}"
  stdout = nil
  code = 0
  cmd = [
    docker, 'run',
    ('--detach' if block_given?),
    '--name', Shellwords.escape(container),
    ("--add-host #{donce_host}:host-gateway" unless OS.linux?),
    args,
    env.map { |k, v| "--env #{Shellwords.escape("#{k}=#{v}")}" }.join(' '),
    ports.map { |k, v| "--publish #{Shellwords.escape("#{k}:#{v}")}" }.join(' '),
    volumes.map { |k, v| "--volume #{Shellwords.escape("#{k}:#{v}")}" }.join(' '),
    ("--user=#{Shellwords.escape("#{Process.uid}:#{Process.gid}")}" unless root),
    Shellwords.escape(img),
    command
  ].compact.join(' ')
  begin
    begin
      stdout, code =
        Timeout.timeout(timeout) do
          qbash(
            cmd,
            log:,
            accept: nil,
            both: true,
            env:
          )
        end
      unless code.zero?
        log.error(stdout)
        raise \
          "Failed to run #{cmd} " \
          "(exit code is ##{code}, stdout has #{stdout.split("\n").count} lines)"
      end
      yield container if block_given?
    ensure
      logs = qbash(
        "#{docker} logs #{Shellwords.escape(container)}",
        level: code.zero? ? Logger::DEBUG : Logger::ERROR,
        log:
      )
      stdout = logs if block_given?
      qbash("#{docker} rm --force #{Shellwords.escape(container)}", log:)
    end
    stdout
  ensure
    Timeout.timeout(10) do
      qbash("#{docker} rmi #{img}", log:) unless image
    end
  end
end

#donce_hostString

The name of the localhost inside Docker container.

Returns:

  • (String)

    The hostname



50
51
52
# File 'lib/donce.rb', line 50

def donce_host
  OS.linux? ? '172.17.0.1' : 'host.docker.internal'
end