From b613267add99ea3cac0e15d3d7d352861d7c7172 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Wed, 8 Jun 2022 13:03:26 +0200 Subject: [PATCH] Remove legacy DockerClient --- config/initializers/mixins.rb | 5 - lib/docker_client.rb | 479 ------------------ lib/docker_container_mixin.rb | 18 - lib/port_pool.rb | 18 - lib/runner/strategy/docker_container_pool.rb | 2 +- lib/tasks/docker.rake | 22 - spec/lib/docker_client_spec.rb | 442 ---------------- spec/lib/docker_container_mixin_spec.rb | 34 -- spec/lib/port_pool_spec.rb | 57 --- .../strategy/docker_container_pool_spec.rb | 2 +- 10 files changed, 2 insertions(+), 1077 deletions(-) delete mode 100644 config/initializers/mixins.rb delete mode 100644 lib/docker_client.rb delete mode 100644 lib/docker_container_mixin.rb delete mode 100644 lib/port_pool.rb delete mode 100644 lib/tasks/docker.rake delete mode 100644 spec/lib/docker_client_spec.rb delete mode 100644 spec/lib/docker_container_mixin_spec.rb delete mode 100644 spec/lib/port_pool_spec.rb diff --git a/config/initializers/mixins.rb b/config/initializers/mixins.rb deleted file mode 100644 index afa8e6b9..00000000 --- a/config/initializers/mixins.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -require 'docker_container_mixin' - -Docker::Container.include DockerContainerMixin diff --git a/lib/docker_client.rb b/lib/docker_client.rb deleted file mode 100644 index 8355e03e..00000000 --- a/lib/docker_client.rb +++ /dev/null @@ -1,479 +0,0 @@ -# frozen_string_literal: true - -require 'pathname' - -class DockerClient - def self.config - @config ||= CodeOcean::Config.new(:docker).read(erb: true) - end - - CONTAINER_WORKSPACE_PATH = '/workspace' # '/home/python/workspace' #'/tmp/workspace' - # Ralf: I suggest to replace this with the environment variable. Ask Hauke why this is not the case! - LOCAL_WORKSPACE_ROOT = File.expand_path(config[:workspace_root]) - RECYCLE_CONTAINERS = false - RETRY_COUNT = 2 - MINIMUM_CONTAINER_LIFETIME = 10.minutes - MAXIMUM_CONTAINER_LIFETIME = 20.minutes - SELF_DESTROY_GRACE_PERIOD = 2.minutes - - attr_reader :container, :socket - attr_accessor :tubesock - - def self.check_availability! - Timeout.timeout(config[:connection_timeout]) { Docker.version } - rescue Excon::Errors::SocketError, Timeout::Error - raise Error.new("The Docker host at #{Docker.url} is not reachable!") - end - - def self.clean_container_workspace(container) - # remove files when using transferral via Docker API archive_in (transmit) - # container.exec(['bash', '-c', 'rm -rf ' + CONTAINER_WORKSPACE_PATH + '/*']) - - local_workspace_path = local_workspace_path(container) - if local_workspace_path && Pathname.new(local_workspace_path).exist? - Pathname.new(local_workspace_path).children.each do |p| - p.rmtree - rescue Errno::ENOENT, Errno::EACCES => e - Sentry.capture_exception(e) - Rails.logger.error("clean_container_workspace: Got #{e.class}: #{e}") - end - # FileUtils.rmdir(Pathname.new(local_workspace_path)) - end - end - - def command_substitutions(filename) - { - class_name: File.basename(filename, File.extname(filename)).upcase_first, - filename: filename, - module_name: File.basename(filename, File.extname(filename)).underscore, - } - end - - private :command_substitutions - - def self.container_creation_options(execution_environment, local_workspace_path) - { - 'Image' => find_image_by_tag(execution_environment.docker_image).info['RepoTags'].first, - 'NetworkDisabled' => !execution_environment.network_enabled?, - 'OpenStdin' => true, - 'StdinOnce' => true, - # required to expose standard streams over websocket - 'AttachStdout' => true, - 'AttachStdin' => true, - 'AttachStderr' => true, - 'Tty' => true, - 'Binds' => mapped_directories(local_workspace_path), - 'PortBindings' => mapped_ports(execution_environment), - # Resource limitations. - 'NanoCPUs' => 4 * 1_000_000_000, # CPU quota in units of 10^-9 CPUs. - 'PidsLimit' => 100, - 'KernelMemory' => execution_environment.memory_limit.megabytes, # if below Memory, the Docker host (!) might experience an OOM - 'Memory' => execution_environment.memory_limit.megabytes, - 'MemorySwap' => execution_environment.memory_limit.megabytes, # same value as Memory to disable Swap - 'OomScoreAdj' => 500, - } - end - - def create_socket(container, stderr: false) - # TODO: factor out query params - # todo separate stderr - query_params = "logs=0&stream=1&#{stderr ? 'stderr=1' : 'stdout=1&stdin=1'}" - - # Headers are required by Docker - headers = {'Origin' => 'http://localhost'} - - socket_url = "#{self.class.config['ws_host']}/v1.27/containers/#{@container.id}/attach/ws?#{query_params}" - # The ping value is measured in seconds and specifies how often a Ping frame should be sent. - # Internally, Faye::WebSocket uses EventMachine and the ping value is used to wake the EventMachine thread - socket = Faye::WebSocket::Client.new(socket_url, [], headers: headers, ping: 0.1) - - Rails.logger.debug { "Opening Websocket on URL #{socket_url}" } - - socket.on :error do |event| - Rails.logger.info "Websocket error: #{event.message}" - end - socket.on :close do |_event| - Rails.logger.info 'Websocket closed.' - end - socket.on :open do |_event| - Rails.logger.info 'Websocket created.' - kill_after_timeout(container) - end - socket - end - - def copy_file_to_workspace(options = {}) - FileUtils.cp(options[:file].native_file.path, local_file_path(options)) - end - - def self.create_container(execution_environment) - # tries ||= 0 - # container.start sometimes creates the passed local_workspace_path on disk (depending on the setup). - # this is however not guaranteed and caused issues on the server already. Therefore create the necessary folders manually! - local_workspace_path = generate_local_workspace_path - FileUtils.mkdir(local_workspace_path) - FileUtils.chmod_R(0o777, local_workspace_path) - container = Docker::Container.create(container_creation_options(execution_environment, local_workspace_path)) - container.start - container.start_time = Time.zone.now - container.status = :created - container.execution_environment = execution_environment - container.docker_client = new(execution_environment: execution_environment) - - Thread.new do - timeout = Random.rand(MINIMUM_CONTAINER_LIFETIME..MAXIMUM_CONTAINER_LIFETIME) # seconds - sleep(timeout) - if container.status == :executing - Thread.new do - timeout = SELF_DESTROY_GRACE_PERIOD.to_i - sleep(timeout) - container.docker_client.kill_container(container) - Rails.logger.info("Force killing container in status #{container.status} after #{Time.zone.now - container.start_time} seconds.") - ensure - # guarantee that the thread is releasing the DB connection after it is done - ActiveRecord::Base.connection_pool.release_connection - end - else - container.docker_client.kill_container(container) - Rails.logger.info("Killing container in status #{container.status} after #{Time.zone.now - container.start_time} seconds.") - end - ensure - # guarantee that the thread is releasing the DB connection after it is done - ActiveRecord::Base.connection_pool.release_connection - end - - container - rescue Docker::Error::NotFoundError => e - Rails.logger.error("create_container: Got Docker::Error::NotFoundError: #{e}") - destroy_container(container) - # (tries += 1) <= RETRY_COUNT ? retry : raise(error) - end - - def create_workspace_files(container, submission) - # clear directory (it should be empty anyhow) - # Pathname.new(self.class.local_workspace_path(container)).children.each{ |p| p.rmtree} - submission.collect_files.each do |file| - FileUtils.mkdir_p(File.join(self.class.local_workspace_path(container), file.path || '')) - if file.file_type.binary? - copy_file_to_workspace(container: container, file: file) - else - create_workspace_file(container: container, file: file) - end - end - FileUtils.chmod_R('+rwX', self.class.local_workspace_path(container)) - rescue Docker::Error::NotFoundError => e - Rails.logger.info("create_workspace_files: Rescued from Docker::Error::NotFoundError: #{e}") - end - - private :create_workspace_files - - def create_workspace_file(options = {}) - # TODO: try catch i/o exception and log failed attempts - file = File.new(local_file_path(options), 'w') - file.write(options[:file].content) - file.close - end - - private :create_workspace_file - - def create_workspace_files_transmit(container, submission) - # create a temporary dir, put all files in it, and put it into the container. the dir is automatically removed when leaving the block. - Dir.mktmpdir do |dir| - submission.collect_files.each do |file| - disk_file = File.new("#{dir}/#{file.path || ''}#{file.name_with_extension}", 'w') - disk_file.write(file.content) - disk_file.close - end - - begin - # create target folder, TODO re-active this when we remove shared folder bindings - # container.exec(['bash', '-c', 'mkdir ' + CONTAINER_WORKSPACE_PATH]) - # container.exec(['bash', '-c', 'chown -R python ' + CONTAINER_WORKSPACE_PATH]) - # container.exec(['bash', '-c', 'chgrp -G python ' + CONTAINER_WORKSPACE_PATH]) - rescue StandardError => e - Rails.logger.error("create workspace folder: Rescued from StandardError: #{e}") - end - - # sleep 1000 - - begin - # tar the files in dir and put the tar to CONTAINER_WORKSPACE_PATH in the container - container.archive_in(dir, CONTAINER_WORKSPACE_PATH, overwrite: false) - rescue StandardError => e - Rails.logger.error("insert tar: Rescued from StandardError: #{e}") - end - - # Rails.logger.info('command: tar -xf ' + CONTAINER_WORKSPACE_PATH + '/' + dir.split('/tmp/')[1] + ' -C ' + CONTAINER_WORKSPACE_PATH) - - begin - # untar the tar file placed in the CONTAINER_WORKSPACE_PATH - container.exec(['bash', '-c', - "tar -xf #{CONTAINER_WORKSPACE_PATH}/#{dir.split('/tmp/')[1]} -C #{CONTAINER_WORKSPACE_PATH}"]) - rescue StandardError => e - Rails.logger.error("untar: Rescued from StandardError: #{e}") - end - - # sleep 1000 - end - rescue StandardError => e - Rails.logger.error("create_workspace_files_transmit: Rescued from StandardError: #{e}") - end - - def self.destroy_container(container) - @socket&.close - Rails.logger.info("destroying container #{container}") - - # Checks only if container assignment is not nil and not whether the container itself is still present. - if container - container.kill - container.port_bindings.each_value {|port| PortPool.release(port) } - begin - clean_container_workspace(container) - FileUtils.rmtree(local_workspace_path(container)) - rescue Errno::ENOENT, Errno::EACCES => e - Sentry.capture_exception(e) - Rails.logger.error("clean_container_workspace: Got #{e.class}: #{e}") - end - - # Checks only if container assignment is not nil and not whether the container itself is still present. - container&.delete(force: true, v: true) - end - rescue Docker::Error::NotFoundError => e - Rails.logger.error("destroy_container: Rescued from Docker::Error::NotFoundError: #{e}") - Rails.logger.error('No further actions are done concerning that.') - rescue Docker::Error::ConflictError => e - Rails.logger.error("destroy_container: Rescued from Docker::Error::ConflictError: #{e}") - Rails.logger.error('No further actions are done concerning that.') - end - - # currently only used to check if containers have been started correctly, or other internal checks - # also used for the admin shell to any container - def execute_arbitrary_command(command, &block) - execute_command(command, nil, block) - end - - # only used by score and execute_arbitrary_command - def execute_command(command, before_execution_block, output_consuming_block) - # tries ||= 0 - container_request_time = Time.zone.now - @container = self.class.create_container(@execution_environment) - waiting_for_container_time = Time.zone.now - container_request_time - if @container - @container.status = :executing - before_execution_block.try(:call) - execution_request_time = Time.zone.now - command_result = send_command(command, @container, &output_consuming_block) - container_execution_time = Time.zone.now - execution_request_time - - command_result[:waiting_for_container_time] = waiting_for_container_time - command_result[:container_execution_time] = container_execution_time - command_result - else - {status: :container_depleted, waiting_for_container_time: waiting_for_container_time, -container_execution_time: nil} - end - rescue Excon::Errors::SocketError - # socket errors seems to be normal when using exec - # so lets ignore them for now - # (tries += 1) <= RETRY_COUNT ? retry : raise(error) - end - - # called when the user clicks the "Run" button - def open_websocket_connection(command, before_execution_block, _output_consuming_block) - @container = self.class.create_container(@execution_environment) - if @container - @container.status = :executing - # do not use try here, directly call the passed proc and rescue from the error in order to log the problem. - # before_execution_block.try(:call) - begin - before_execution_block.call - rescue FilepathError - # Prevent catching this error here - raise - rescue StandardError => e - Rails.logger.error("execute_websocket_command: Rescued from StandardError caused by before_execution_block.call: #{e}") - end - # TODO: catch exception if socket could not be created - @socket ||= create_socket(@container) - {status: :container_running, socket: @socket, container: @container, command: command} - else - {status: :container_depleted} - end - end - - def kill_after_timeout(container) - # We need to start a second thread to kill the websocket connection, - # as it is impossible to determine whether further input is requested. - container.status = :executing - @thread = Thread.new do - timeout = @execution_environment.permitted_execution_time.to_i # seconds - sleep(timeout) - if container && container.status != :available - Rails.logger.info("Killing container after timeout of #{timeout} seconds.") - # send timeout to the tubesock socket - # FIXME: 2nd thread to notify user. - @tubesock&.send_data JSON.dump({'cmd' => 'timeout'}) - if @socket - begin - @socket.send('#timeout') # rubocop:disable Performance/StringIdentifierArgument - # sleep one more second to ensure that the message reaches the submissions_controller. - sleep(1) - @socket.close - rescue RuntimeError => e - Rails.logger.error(e) - end - end - Thread.new do - kill_container(container) - ensure - ActiveRecord::Base.connection_pool.release_connection - end - else - Rails.logger.info("Container#{container} already removed.") - end - ensure - # guarantee that the thread is releasing the DB connection after it is done - ActiveRecord::Base.connection_pool.release_connection - end - end - - def exit_thread_if_alive - @thread.exit if @thread&.alive? - end - - def exit_container(container) - Rails.logger.debug { "exiting container #{container}" } - # exit the timeout thread if it is still alive - exit_thread_if_alive - @socket.close - self.class.destroy_container(container) - end - - def kill_container(container) - exit_thread_if_alive - Rails.logger.info("killing container #{container}") - self.class.destroy_container(container) - end - - def execute_run_command(submission, filename, &block) - # Run commands by attaching a websocket to Docker. - filepath = submission.collect_files.find {|f| f.name_with_extension == filename }.filepath - command = submission.execution_environment.run_command % command_substitutions(filepath) - create_workspace_files = proc { create_workspace_files(container, submission) } - open_websocket_connection(command, create_workspace_files, block) - # actual run command is run in the submissions controller, after all listeners are attached. - end - - def execute_test_command(submission, filename, &block) - # Stick to existing Docker API with exec command. - file = submission.collect_files.find {|f| f.name_with_extension == filename } - filepath = file.filepath - command = submission.execution_environment.test_command % command_substitutions(filepath) - create_workspace_files = proc { create_workspace_files(container, submission) } - test_result = execute_command(command, create_workspace_files, block) - test_result[:file_role] = file.role - test_result - end - - def self.find_image_by_tag(tag) - # TODO: cache this. - Docker::Image.all.detect do |image| - image.info['RepoTags'].flatten.include?(tag) - rescue StandardError - # Skip image if it is not tagged - next - end - end - - def self.generate_local_workspace_path - File.join(LOCAL_WORKSPACE_ROOT, SecureRandom.uuid) - end - - def self.image_tags - Docker::Image.all.map {|image| image.info['RepoTags'] }.flatten.reject {|tag| tag.nil? || tag.include?('') } - end - - def initialize(options = {}) - @execution_environment = options[:execution_environment] - # TODO: eventually re-enable this if it is cached. But in the end, we do not need this. - # docker daemon got much too much load. all not 100% necessary calls to the daemon were removed. - # @image = self.class.find_image_by_tag(@execution_environment.docker_image) - # fail(Error, "Cannot find image #{@execution_environment.docker_image}!") unless @image - end - - def self.initialize_environment - raise Error.new('Docker configuration missing!') unless config[:connection_timeout] && config[:workspace_root] - - Docker.url = config[:host] if config[:host] - # TODO: availability check disabled for performance reasons. Reconsider if this is necessary. - # docker daemon got much too much load. all not 100% necessary calls to the daemon were removed. - # check_availability! - FileUtils.mkdir_p(LOCAL_WORKSPACE_ROOT) - end - - def local_file_path(options = {}) - resulting_file_path = File.join(self.class.local_workspace_path(options[:container]), options[:file].path || '', - options[:file].name_with_extension) - absolute_path = File.expand_path(resulting_file_path) - unless absolute_path.start_with? self.class.local_workspace_path(options[:container]).to_s - raise FilepathError.new('Filepath not allowed') - end - - absolute_path - end - - private :local_file_path - - def self.local_workspace_path(container) - Pathname.new(container.binds.first.split(':').first) if container.binds.present? - end - - def self.mapped_directories(local_workspace_path) - # create the string to be returned - ["#{local_workspace_path}:#{CONTAINER_WORKSPACE_PATH}"] - end - - def self.mapped_ports(execution_environment) - execution_environment.exposed_ports.to_h do |port| - ["#{port}/tcp", [{'HostPort' => PortPool.available_port.to_s}]] - end - end - - def self.pull(docker_image) - `docker pull #{docker_image}` if docker_image - end - - def send_command(command, container) - result = {status: :failed, stdout: '', stderr: ''} - output = nil - Timeout.timeout(@execution_environment.permitted_execution_time.to_i) do - # TODO: check phusion doku again if we need -i -t options here - # https://stackoverflow.com/questions/363223/how-do-i-get-both-stdout-and-stderr-to-go-to-the-terminal-and-a-log-file - output = container.exec( - ['bash', '-c', - "#{command} 1> >(tee -a /tmp/stdout.log) 2> >(tee -a /tmp/stderr.log >&2); rm -f /tmp/std*.log"], tty: false - ) - end - Rails.logger.debug 'output from container.exec' - Rails.logger.debug output - if output.nil? - kill_container(container) - else - result = {status: (output[2])&.zero? ? :ok : :failed, stdout: output[0].join.force_encoding('utf-8'), stderr: output[1].join.force_encoding('utf-8')} - end - - self.class.destroy_container(container) - result - rescue Timeout::Error - Rails.logger.info("got timeout error for container #{container}") - stdout = container.exec(%w[cat /tmp/stdout.log])[0].join.force_encoding('utf-8') - stderr = container.exec(%w[cat /tmp/stderr.log])[0].join.force_encoding('utf-8') - kill_container(container) - {status: :timeout, stdout: stdout, stderr: stderr} - end - private :send_command - - class Error < RuntimeError; end - - class FilepathError < RuntimeError; end -end diff --git a/lib/docker_container_mixin.rb b/lib/docker_container_mixin.rb deleted file mode 100644 index d2785263..00000000 --- a/lib/docker_container_mixin.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module DockerContainerMixin - attr_accessor :start_time, :status, :execution_environment, :docker_client - - def binds - host_config['Binds'] - end - - def port_bindings - # Don't use cached version as this might be changed during runtime - json['HostConfig']['PortBindings'].try(:map) {|key, value| [key.to_i, value.first['HostPort'].to_i] }.to_h - end - - def host_config - @host_config ||= json['HostConfig'] - end -end diff --git a/lib/port_pool.rb b/lib/port_pool.rb deleted file mode 100644 index e32b5438..00000000 --- a/lib/port_pool.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class PortPool - PORT_RANGE = DockerClient.config[:ports] - - @available_ports = PORT_RANGE.to_a - @mutex = Mutex.new - - def self.available_port - @mutex.synchronize do - @available_ports.delete(@available_ports.sample) - end - end - - def self.release(port) - @available_ports << port if PORT_RANGE.include?(port) && @available_ports.exclude?(port) - end -end diff --git a/lib/runner/strategy/docker_container_pool.rb b/lib/runner/strategy/docker_container_pool.rb index cf328244..4de46bba 100644 --- a/lib/runner/strategy/docker_container_pool.rb +++ b/lib/runner/strategy/docker_container_pool.rb @@ -213,7 +213,7 @@ class Runner::Strategy::DockerContainerPool < Runner::Strategy end def local_workspace_path - @local_workspace_path ||= Pathname.new(container.binds.first.split(':').first) + @local_workspace_path ||= Pathname.new(container.json['HostConfig']['Binds'].first.split(':').first) end def reset_inactivity_timer diff --git a/lib/tasks/docker.rake b/lib/tasks/docker.rake deleted file mode 100644 index a0c89a89..00000000 --- a/lib/tasks/docker.rake +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -namespace :docker do - desc 'Remove all Docker containers and dangling Docker images (using the CLI)' - task clean_up: :environment do - `test -n "$(docker ps --all --quiet)" && docker rm --force $(docker ps --all --quiet)` - `test -n "docker images --filter dangling=true --quiet" && docker rmi $(docker images --filter dangling=true --quiet)` - end - - desc 'List all installed Docker images' - task images: :environment do - puts DockerClient.image_tags - end - - desc 'Pull all Docker images referenced by execution environments' - task pull: :environment do - ExecutionEnvironment.all.map(&:docker_image).each do |docker_image| - puts "Pulling #{docker_image}..." - DockerClient.pull(docker_image) - end - end -end diff --git a/spec/lib/docker_client_spec.rb b/spec/lib/docker_client_spec.rb deleted file mode 100644 index 9d502d17..00000000 --- a/spec/lib/docker_client_spec.rb +++ /dev/null @@ -1,442 +0,0 @@ -# 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: build(:java), user: build(:admin)) } - let(:execution_environment) { build(:java) } - let(:image) { double } - let(:submission) { create(:submission) } - let(:workspace_path) { WORKSPACE_PATH } - - before do - docker_image = Docker::Image.new(Docker::Connection.new('http://example.org', {}), 'id' => SecureRandom.hex, 'RepoTags' => [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) { 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 diff --git a/spec/lib/docker_container_mixin_spec.rb b/spec/lib/docker_container_mixin_spec.rb deleted file mode 100644 index 6f9267e0..00000000 --- a/spec/lib/docker_container_mixin_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe DockerContainerMixin do - let(:container) { Docker::Container.send(:new, Docker::Connection.new('http://example.org', {}), 'id' => SecureRandom.hex) } - - describe '#binds' do - let(:binds) { [] } - - it 'is defined for Docker::Container' do - expect(Docker::Container.instance_methods).to include(:binds) - end - - it 'returns the correct information' do - allow(container).to receive(:json).and_return('HostConfig' => {'Binds' => binds}) - expect(container.binds).to eq(binds) - end - end - - describe '#port_bindings' do - let(:port) { 1234 } - let(:port_bindings) { {"#{port}/tcp" => [{'HostIp' => '', 'HostPort' => port.to_s}]} } - - it 'is defined for Docker::Container' do - expect(Docker::Container.instance_methods).to include(:port_bindings) - end - - it 'returns the correct information' do - allow(container).to receive(:json).and_return('HostConfig' => {'PortBindings' => port_bindings}) - expect(container.port_bindings).to eq(port => port) - end - end -end diff --git a/spec/lib/port_pool_spec.rb b/spec/lib/port_pool_spec.rb deleted file mode 100644 index 6f38b8ab..00000000 --- a/spec/lib/port_pool_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe PortPool do - describe '.available_port' do - it 'is synchronized' do - expect(described_class.instance_variable_get(:@mutex)).to receive(:synchronize) - described_class.available_port - end - - context 'when a port is available' do - it 'returns the port' do - expect(described_class.available_port).to be_a(Numeric) - end - - it 'removes the port from the list of available ports' do - port = described_class.available_port - expect(described_class.instance_variable_get(:@available_ports)).not_to include(port) - end - end - - context 'when no port is available' do - it 'returns the port' do - available_ports = described_class.instance_variable_get(:@available_ports) - described_class.instance_variable_set(:@available_ports, []) - expect(described_class.available_port).to be_nil - described_class.instance_variable_set(:@available_ports, available_ports) - end - end - end - - describe '.release' do - context 'when the port has been obtained earlier' do - it 'adds the port to the list of available ports' do - port = described_class.available_port - expect(described_class.instance_variable_get(:@available_ports)).not_to include(port) - described_class.release(port) - expect(described_class.instance_variable_get(:@available_ports)).to include(port) - end - end - - context 'when the port has not been obtained earlier' do - it 'does not add the port to the list of available ports' do - port = described_class.instance_variable_get(:@available_ports).sample - expect { described_class.release(port) }.not_to change { described_class.instance_variable_get(:@available_ports).length } - end - end - - context 'when the port is not included in the port range' do - it 'does not add the port to the list of available ports' do - port = nil - expect { described_class.release(port) }.not_to change { described_class.instance_variable_get(:@available_ports).length } - end - end - end -end diff --git a/spec/lib/runner/strategy/docker_container_pool_spec.rb b/spec/lib/runner/strategy/docker_container_pool_spec.rb index d785867d..23157b42 100644 --- a/spec/lib/runner/strategy/docker_container_pool_spec.rb +++ b/spec/lib/runner/strategy/docker_container_pool_spec.rb @@ -182,7 +182,7 @@ describe Runner::Strategy::DockerContainerPool do it 'returns the local part of the mount binding' do local_path = 'tmp/container20' - allow(container).to receive(:binds).and_return(["#{local_path}:/workspace"]) + allow(container).to receive(:json).and_return({HostConfig: {Binds: ["#{local_path}:/workspace"]}}.as_json) expect(container_pool.send(:local_workspace_path)).to eq(Pathname.new(local_path)) end end