spec/cc/analyzer/container_spec.rb (210 lines of code) (raw):
require "spec_helper"
module CC::Analyzer
describe Container do
describe "#run" do
it "spawns docker run with the image, name, and options given" do
container = Container.new(image: "codeclimate/foo", name: "name")
expect_spawn(%w[ docker run --name name -i -t codeclimate/foo ])
container.run(%w[ -i -t ])
end
it "spawns the command if present" do
container = Container.new(image: "codeclimate/foo", command: "bar", name: "name")
expect_spawn(%w[ docker run --name name codeclimate/foo bar ])
container.run
end
it "spawns an array command if given" do
container = Container.new(image: "codeclimate/foo", command: %w[ bar baz ], name: "name")
expect_spawn(%w[ docker run --name name codeclimate/foo bar baz ])
container.run
end
it "spawns an array command with spaces" do
container = Container.new(
image: "codeclimate/foo",
command: %w[ bar baz\ bat ],
name: "name",
)
expect_spawn(%w[ docker run --name name codeclimate/foo bar baz\ bat ])
container.run
end
it "sends output to the defined handler splitting on the defined delimiter" do
collected_output = []
container = Container.new(image: "codeclimate/foo", name: "name")
container.on_output("\0") { |output| collected_output << output }
out = StringIO.new
out.write("foo\0bar\0")
out.rewind
stub_spawn(out: out)
container.run
expect(collected_output).to eq %w[ foo bar ]
end
it "returns a result object" do
container = Container.new(image: "codeclimate/foo", name: "name")
stub_spawn
result = container.run
expect(result.exit_status).to eq 0
expect(result.timed_out?).to eq false
expect(result.duration).to be_between(-1, 2_000)
expect(result.stderr).to eq ""
end
# N.B. these specs actually docker-runs things. This logic is critical and
# so the real-world interaction is valuable.
describe "stopping containers", slow: true do
before do
@name = "codeclimate-container-test"
system("docker kill #{@name} &>/dev/null")
system("docker rm #{@name} &>/dev/null")
end
it "can be stopped" do
container = Container.new(
image: "alpine",
command: %w[sleep 10],
name: @name,
)
run_container(container) do |c|
# it needs a second to boot before stop will work
sleep 2
c.stop
end
assert_container_stopped
expect(@container_result.timed_out?).to eq false
expect(@container_result.exit_status).to be_present
expect(@container_result.duration).to be_between(-1, 10_000)
end
it "times out slow containers" do
with_timeout(1) do
container = Container.new(
image: "alpine",
command: %w[sleep 10],
name: @name,
)
run_container(container)
assert_container_stopped
expect(@container_result.timed_out?).to eq true
expect(@container_result.exit_status).to be_present
expect(@container_result.duration).to be_between(-1, 2_000)
end
end
it "waits for IO parsing to finish" do
stdout_lines = []
container = Container.new(
image: "alpine",
command: ["echo", "line1\nline2\nline3"],
name: @name,
)
container.on_output do |str|
sleep 0.5
stdout_lines << str
end
run_container(container)
assert_container_stopped
expect(@container_result.timed_out?).to eq false
expect(stdout_lines).to eq %w[line1 line2 line3]
end
it "does not wait for IO when timed out" do
with_timeout(1) do
container = Container.new(
image: "alpine",
#command: %w[sleep 10],
command: ["echo", "line1\nline2\nline3"],
name: @name,
)
container.on_output do |str|
sleep 10 and raise "Reader thread was not killed"
end
run_container(container)
assert_container_stopped
end
end
it "stops containers that emit more than the configured maximum output bytes" do
begin
ENV["CONTAINER_MAXIMUM_OUTPUT_BYTES"] = "4"
container = Container.new(
image: "alpine",
command: ["echo", "hello"],
name: @name,
)
run_container(container)
assert_container_stopped
expect(@container_result.maximum_output_exceeded?).to eq true
expect(@container_result.timed_out?).to eq false
expect(@container_result.exit_status).to be_present
expect(@container_result.output_byte_count).to be > 4
ensure
ENV.delete("CONTAINER_MAXIMUM_OUTPUT_BYTES")
end
end
it "rescues and records metrics when containers fail to stop" do
with_timeout(1) do
name = "cc-engines-rubocop-stable-abc-123"
container = Container.new(
image: "alpine",
command: %w[sleep 10],
name: "cc-engines-rubocop-stable-abc-123",
)
allow(POSIX::Spawn::Child).to receive(:new).
and_raise(POSIX::Spawn::TimeoutExceeded)
expect(CC::Analyzer.logger).to receive(:error)
expect(CC::Analyzer.statsd).to receive(:increment).
with("container.zombie")
expect(CC::Analyzer.statsd).to receive(:increment).
with("container.zombie.engine.rubocop.stable")
begin
run_container(container)
ensure
# Cleanup manually
system("docker stop #{name} >/dev/null")
system("docker rm #{name} >/dev/null")
end
end
end
def run_container(container)
thread = Thread.new { @container_result = container.run }
if block_given?
yield container
else
container
end
ensure
thread&.join
end
def assert_container_stopped
expect(`docker ps --quiet --filter name=#{@name}`.strip).to eq ""
end
end
end
describe "#run when the process exits with a non-zero status" do
before do
@container = Container.new(image: "codeclimate/foo", name: "name")
err = StringIO.new
err.puts("error one")
err.puts("error two")
err.rewind
status = double("Process::Status", exitstatus: 123)
stub_spawn(status: status, err: err)
end
it "returns a result object" do
result = @container.run
expect(result.exit_status).to eq 123
expect(result.timed_out?).to eq false
expect(result.duration).to be_present
expect(result.stderr).to eq "error one\nerror two\n"
end
end
def stub_spawn(status: nil, out: StringIO.new, err: StringIO.new)
pid = 42
status ||= double("Process::Status", exitstatus: 0)
allow(POSIX::Spawn).to receive(:popen4).and_return([pid, nil, out, err])
allow(Process).to receive(:waitpid2).with(pid).and_return([nil, status])
return [pid, out, err]
end
def expect_spawn(args, status: nil, out: StringIO.new, err: StringIO.new)
pid = 42
status ||= double("Process::Status", exitstatus: 0)
expect(POSIX::Spawn).to receive(:popen4).with(*args).and_return([pid, nil, out, err])
expect(Process).to receive(:waitpid2).with(pid).and_return([nil, status])
return [pid, out, err]
end
def with_timeout(timeout)
old_timeout = ENV["CONTAINER_TIMEOUT_SECONDS"]
ENV["CONTAINER_TIMEOUT_SECONDS"] = timeout.to_s
yield
ensure
ENV["CONTAINER_TIMEOUT_SECONDS"] = old_timeout
end
end
end