Module: Kernel

Defined in:
lib/donce.rb

Overview

Execute Docker container and clean up afterwards.

This function helps build temporary Docker images, runs Docker containers, and cleans 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:

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-2026 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| ... } ⇒ String

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

Yields:

  • (String)

    Container ID if block is given (runs container in daemon mode)

Yield Parameters:

  • id (String)

    The ID of the running container

Yield Returns:

  • (void)

    The container will be stopped after the block execution



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
166
167
168
169
170
171
172
# File 'lib/donce.rb', line 73

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?
  ports = ports.to_h { |x| [x, x] } if ports.is_a?(Array)
  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.merge({ GID: Process.gid, UID: Process.uid }).map do |k, v|
          "--build-arg #{Shellwords.escape("#{k}=#{v}")}"
        end.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.



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

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