443 lines
16 KiB
Ruby
443 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
require 'seeds_helper'
|
|
|
|
WORKSPACE_PATH = Rails.root.join('tmp', 'files', Rails.env, 'code_ocean_test')
|
|
|
|
describe DockerClient do
|
|
let(:command) { 'whoami' }
|
|
let(:docker_client) { described_class.new(execution_environment: FactoryBot.build(:java), user: FactoryBot.build(:admin)) }
|
|
let(:execution_environment) { FactoryBot.build(:java) }
|
|
let(:image) { double }
|
|
let(:submission) { FactoryBot.create(:submission) }
|
|
let(:workspace_path) { WORKSPACE_PATH }
|
|
|
|
before do
|
|
docker_image = Docker::Image.new(Docker::Connection.new('http://example.org', {}), 'id' => SecureRandom.hex, 'RepoTags' => [FactoryBot.attributes_for(:java)[:docker_image]])
|
|
allow(described_class).to receive(:find_image_by_tag).and_return(docker_image)
|
|
described_class.initialize_environment
|
|
allow(described_class).to receive(:container_creation_options).and_wrap_original do |original_method, *args, &block|
|
|
result = original_method.call(*args, &block)
|
|
result['NanoCPUs'] = 2 * 1_000_000_000 # CPU quota in units of 10^-9 CPUs.
|
|
result
|
|
end
|
|
end
|
|
|
|
describe '.check_availability!' do
|
|
context 'when a socket error occurs' do
|
|
it 'raises an error' do
|
|
allow(Docker).to receive(:version).and_raise(Excon::Errors::SocketError.new(StandardError.new))
|
|
expect { described_class.check_availability! }.to raise_error(DockerClient::Error)
|
|
end
|
|
end
|
|
|
|
context 'when a timeout occurs' do
|
|
it 'raises an error' do
|
|
allow(Docker).to receive(:version).and_raise(Timeout::Error)
|
|
expect { described_class.check_availability! }.to raise_error(DockerClient::Error)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.container_creation_options' do
|
|
let(:container_creation_options) { described_class.container_creation_options(execution_environment, workspace_path) }
|
|
|
|
it 'specifies the Docker image' do
|
|
expect(container_creation_options).to include('Image' => described_class.find_image_by_tag(execution_environment.docker_image).info['RepoTags'].first)
|
|
end
|
|
|
|
it 'specifies the memory limit' do
|
|
expect(container_creation_options).to include('Memory' => execution_environment.memory_limit.megabytes)
|
|
end
|
|
|
|
it 'specifies whether network access is enabled' do
|
|
expect(container_creation_options).to include('NetworkDisabled' => !execution_environment.network_enabled?)
|
|
end
|
|
|
|
it 'specifies to open the standard input stream once' do
|
|
expect(container_creation_options).to include('OpenStdin' => true, 'StdinOnce' => true)
|
|
end
|
|
|
|
it 'specifies mapped directories' do
|
|
expect(container_creation_options).to include('Binds' => kind_of(Array))
|
|
end
|
|
|
|
it 'specifies mapped ports' do
|
|
expect(container_creation_options).to include('PortBindings' => kind_of(Hash))
|
|
end
|
|
end
|
|
|
|
describe '.create_container' do
|
|
let(:create_container) { described_class.create_container(execution_environment) }
|
|
|
|
after do
|
|
FileUtils.rm_rf(workspace_path)
|
|
end
|
|
|
|
it 'uses the correct Docker image' do
|
|
expect(described_class).to receive(:find_image_by_tag).with(execution_environment.docker_image).and_call_original
|
|
container = create_container
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
it 'creates a unique directory' do
|
|
expect(described_class).to receive(:generate_local_workspace_path).and_call_original
|
|
expect(FileUtils).to receive(:mkdir).with(kind_of(String)).and_call_original
|
|
container = create_container
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
it 'creates a container' do
|
|
local_workspace_path = File.join(workspace_path, 'example').to_s
|
|
FileUtils.mkdir_p(workspace_path)
|
|
allow(described_class).to receive(:generate_local_workspace_path).and_return(local_workspace_path)
|
|
expect(described_class).to receive(:container_creation_options).with(execution_environment, local_workspace_path)
|
|
.and_wrap_original do |original_method, *args, &block|
|
|
result = original_method.call(*args, &block)
|
|
result['NanoCPUs'] = 2 * 1_000_000_000 # CPU quota in units of 10^-9 CPUs.
|
|
result
|
|
end
|
|
expect(Docker::Container).to receive(:create).with(kind_of(Hash)).and_call_original
|
|
container = create_container
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
it 'starts the container' do
|
|
expect_any_instance_of(Docker::Container).to receive(:start).and_call_original
|
|
container = create_container
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
it 'configures mapped directories' do
|
|
expect(described_class).to receive(:mapped_directories).and_call_original
|
|
container = create_container
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
it 'configures mapped ports' do
|
|
expect(described_class).to receive(:mapped_ports).with(execution_environment).and_call_original
|
|
container = create_container
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
context 'when an error occurs' do
|
|
let(:error) { Docker::Error::NotFoundError.new }
|
|
|
|
context 'when retries are left' do
|
|
before do
|
|
allow(described_class).to receive(:mapped_directories).and_raise(error).and_call_original
|
|
end
|
|
|
|
it 'retries to create a container' do
|
|
container = create_container
|
|
expect(container).to be_a(Docker::Container)
|
|
described_class.destroy_container(container)
|
|
end
|
|
end
|
|
|
|
context 'when no retries are left' do
|
|
before do
|
|
allow(described_class).to receive(:mapped_directories).exactly(DockerClient::RETRY_COUNT + 1).times.and_raise(error)
|
|
end
|
|
|
|
it 'raises the error' do
|
|
pending('RETRY COUNT is disabled')
|
|
expect { create_container }.to raise_error(error)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#create_workspace_files' do
|
|
let(:container) { double }
|
|
|
|
before do
|
|
allow(container).to receive(:binds).at_least(:once).and_return(["#{workspace_path}:#{DockerClient::CONTAINER_WORKSPACE_PATH}"])
|
|
end
|
|
|
|
after { docker_client.send(:create_workspace_files, container, submission) }
|
|
|
|
it 'creates submission-specific directories' do
|
|
expect(Dir).to receive(:mkdir).at_least(:once).and_call_original
|
|
end
|
|
|
|
it 'copies binary files' do
|
|
submission.collect_files.select {|file| file.file_type.binary? }.each do |file|
|
|
expect(docker_client).to receive(:copy_file_to_workspace).with(container: container, file: file)
|
|
end
|
|
end
|
|
|
|
it 'creates non-binary files' do
|
|
submission.collect_files.reject {|file| file.file_type.binary? }.each do |file|
|
|
expect(docker_client).to receive(:create_workspace_file).with(container: container, file: file)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#create_workspace_file' do
|
|
let(:container) { Docker::Container.send(:new, Docker::Connection.new('http://example.org', {}), 'id' => SecureRandom.hex) }
|
|
let(:file) { FactoryBot.build(:file, content: 'puts 42') }
|
|
let(:file_path) { File.join(workspace_path, file.name_with_extension) }
|
|
|
|
after { File.delete(file_path) }
|
|
|
|
it 'creates a file' do
|
|
expect(described_class).to receive(:local_workspace_path).at_least(:once).and_return(workspace_path)
|
|
FileUtils.mkdir_p(workspace_path)
|
|
docker_client.send(:create_workspace_file, container: container, file: file)
|
|
expect(File.exist?(file_path)).to be true
|
|
expect(File.new(file_path, 'r').read).to eq(file.content)
|
|
end
|
|
end
|
|
|
|
describe '.destroy_container' do
|
|
let(:container) { described_class.create_container(execution_environment) }
|
|
|
|
after { described_class.destroy_container(container) }
|
|
|
|
it 'kills running processes' do
|
|
allow(container).to receive(:kill).and_return(container)
|
|
end
|
|
|
|
it 'releases allocated ports' do
|
|
allow(container).to receive(:port_bindings).at_least(:once).and_return(foo: [{'HostPort' => '42'}])
|
|
expect(PortPool).to receive(:release)
|
|
end
|
|
|
|
it 'removes the mapped directory' do
|
|
expect(described_class).to receive(:local_workspace_path).at_least(:once).and_return(workspace_path)
|
|
# !TODO Fix this
|
|
# expect(PathName).to receive(:rmtree).with(workspace_path)
|
|
end
|
|
|
|
it 'deletes the container' do
|
|
expect(container).to receive(:delete).with(force: true, v: true).and_call_original
|
|
end
|
|
end
|
|
|
|
describe '#execute_arbitrary_command' do
|
|
let(:execute_arbitrary_command) { docker_client.execute_arbitrary_command(command) }
|
|
|
|
after { described_class.destroy_container(docker_client.container) }
|
|
|
|
it 'creates a new container' do
|
|
expect(described_class).to receive(:create_container).and_call_original
|
|
execute_arbitrary_command
|
|
end
|
|
|
|
it 'sends the command' do
|
|
allow(docker_client).to receive(:send_command).with(command, kind_of(Docker::Container)).and_return({})
|
|
execute_arbitrary_command
|
|
end
|
|
|
|
context 'when a socket error occurs' do
|
|
let(:error) { Excon::Errors::SocketError.new(SocketError.new) }
|
|
|
|
context 'when retries are left' do
|
|
let(:result) { {status: 'ok', stdout: 42} }
|
|
|
|
before do
|
|
allow(docker_client).to receive(:send_command).and_raise(error).and_return(result)
|
|
end
|
|
|
|
it 'retries to execute the command' do
|
|
expect(execute_arbitrary_command[:stdout]).to eq(result[:stdout])
|
|
end
|
|
end
|
|
|
|
context 'when no retries are left' do
|
|
before do
|
|
allow(docker_client).to receive(:send_command).exactly(DockerClient::RETRY_COUNT + 1).times.and_raise(error)
|
|
end
|
|
|
|
it 'raises the error' do
|
|
pending('retries are disabled')
|
|
# TODO: Retries is disabled
|
|
expect { execute_arbitrary_command }.to raise_error(error)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#execute_run_command' do
|
|
let(:filename) { submission.exercise.files.detect {|file| file.role == 'main_file' }.name_with_extension }
|
|
|
|
after do
|
|
docker_client.send(:execute_run_command, submission, filename)
|
|
described_class.destroy_container(docker_client.container)
|
|
end
|
|
|
|
it 'creates a new container' do
|
|
expect(described_class).to receive(:create_container).with(submission.execution_environment).and_call_original
|
|
end
|
|
|
|
it 'creates the workspace files' do
|
|
expect(docker_client).to receive(:create_workspace_files)
|
|
end
|
|
|
|
it 'executes the run command' do
|
|
pending('todo in the future')
|
|
expect(submission.execution_environment).to receive(:run_command).and_call_original
|
|
expect(docker_client).to receive(:send_command).with(kind_of(String), kind_of(Docker::Container))
|
|
end
|
|
end
|
|
|
|
describe '#execute_test_command' do
|
|
let(:filename) { submission.exercise.files.detect {|file| file.role == 'teacher_defined_test' || file.role == 'teacher_defined_linter' }.name_with_extension }
|
|
|
|
after do
|
|
docker_client.send(:execute_test_command, submission, filename)
|
|
described_class.destroy_container(docker_client.container)
|
|
end
|
|
|
|
it 'creates a new container' do
|
|
expect(described_class).to receive(:create_container).with(submission.execution_environment).and_call_original
|
|
end
|
|
|
|
it 'creates the workspace files' do
|
|
expect(docker_client).to receive(:create_workspace_files)
|
|
end
|
|
|
|
it 'executes the test command' do
|
|
expect(submission.execution_environment).to receive(:test_command).and_call_original
|
|
allow(docker_client).to receive(:send_command).with(kind_of(String), kind_of(Docker::Container)).and_return({})
|
|
end
|
|
end
|
|
|
|
describe '.generate_local_workspace_path' do
|
|
it 'includes the correct workspace root' do
|
|
expect(described_class.generate_local_workspace_path.to_s).to start_with(DockerClient::LOCAL_WORKSPACE_ROOT.to_s)
|
|
end
|
|
|
|
it 'includes a UUID' do
|
|
expect(SecureRandom).to receive(:uuid).and_call_original
|
|
described_class.generate_local_workspace_path
|
|
end
|
|
end
|
|
|
|
describe '.initialize_environment' do
|
|
context 'with complete configuration' do
|
|
it 'creates the file directory' do
|
|
expect(FileUtils).to receive(:mkdir_p).with(DockerClient::LOCAL_WORKSPACE_ROOT)
|
|
described_class.initialize_environment
|
|
end
|
|
end
|
|
|
|
context 'with incomplete configuration' do
|
|
before { allow(described_class).to receive(:config).at_least(:once).and_return({}) }
|
|
|
|
it 'raises an error' do
|
|
expect { described_class.initialize_environment }.to raise_error(DockerClient::Error)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.local_workspace_path' do
|
|
let(:container) { described_class.create_container(execution_environment) }
|
|
let(:local_workspace_path) { described_class.local_workspace_path(container) }
|
|
|
|
after { described_class.destroy_container(container) }
|
|
|
|
it 'returns a path' do
|
|
expect(local_workspace_path).to be_a(Pathname)
|
|
end
|
|
|
|
it 'includes the correct workspace root' do
|
|
expect(local_workspace_path.to_s).to start_with(DockerClient::LOCAL_WORKSPACE_ROOT.to_s)
|
|
end
|
|
end
|
|
|
|
describe '.mapped_directories' do
|
|
it 'returns a unique mapping' do
|
|
mapping = described_class.mapped_directories(workspace_path).first
|
|
expect(mapping).to start_with(workspace_path.to_s)
|
|
expect(mapping).to end_with(DockerClient::CONTAINER_WORKSPACE_PATH)
|
|
end
|
|
end
|
|
|
|
describe '.mapped_ports' do
|
|
context 'with exposed ports' do
|
|
before { execution_environment.exposed_ports = [3000] }
|
|
|
|
it 'returns a mapping' do
|
|
expect(described_class.mapped_ports(execution_environment)).to be_a(Hash)
|
|
expect(described_class.mapped_ports(execution_environment).length).to eq(1)
|
|
end
|
|
|
|
it 'retrieves available ports' do
|
|
expect(PortPool).to receive(:available_port)
|
|
described_class.mapped_ports(execution_environment)
|
|
end
|
|
end
|
|
|
|
context 'without exposed ports' do
|
|
it 'returns an empty mapping' do
|
|
expect(described_class.mapped_ports(execution_environment)).to eq({})
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#send_command' do
|
|
let(:block) { proc {} }
|
|
let(:container) { described_class.create_container(execution_environment) }
|
|
let(:send_command) { docker_client.send(:send_command, command, container, &block) }
|
|
|
|
after do
|
|
send_command
|
|
described_class.destroy_container(container)
|
|
end
|
|
|
|
it 'limits the execution time' do
|
|
expect(Timeout).to receive(:timeout).at_least(:once).with(kind_of(Numeric)).and_call_original
|
|
end
|
|
|
|
it 'provides the command to be executed as input' do
|
|
pending('we are currently not using attach but rather exec.')
|
|
expect(container).to receive(:attach).with(stdin: kind_of(StringIO))
|
|
end
|
|
|
|
it 'calls the block' do
|
|
pending('block is no longer called, see revision 4cbf9970b13362efd4588392cafe4f7fd7cb31c3 to get information how it was done before.')
|
|
expect(block).to receive(:call)
|
|
end
|
|
|
|
context 'when a timeout occurs' do
|
|
before do
|
|
exec_called = 0
|
|
allow(container).to receive(:exec) do
|
|
exec_called += 1
|
|
raise Timeout::Error if exec_called == 1
|
|
|
|
[[], []]
|
|
end
|
|
end
|
|
|
|
it 'destroys the container asynchronously' do
|
|
pending('Container is destroyed, but not as expected in this test. ToDo update this test.')
|
|
expect(Concurrent::Future).to receive(:execute)
|
|
end
|
|
|
|
it 'returns a corresponding status' do
|
|
expect(send_command[:status]).to eq(:timeout)
|
|
end
|
|
end
|
|
|
|
context 'when the container terminates timely' do
|
|
it 'destroys the container asynchronously' do
|
|
pending('Container is destroyed, but not as expected in this test. ToDo update this test.')
|
|
expect(Concurrent::Future).to receive(:execute)
|
|
end
|
|
|
|
it "returns the container's output" do
|
|
expect(send_command[:stderr]).to be_blank
|
|
expect(send_command[:stdout]).to start_with('user')
|
|
end
|
|
|
|
it 'returns a corresponding status' do
|
|
expect(send_command[:status]).to eq(:ok)
|
|
end
|
|
end
|
|
end
|
|
end
|