Remove legacy DockerClient
This commit is contained in:
@ -1,5 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'docker_container_mixin'
|
||||
|
||||
Docker::Container.include DockerContainerMixin
|
@ -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?('<none>') }
|
||||
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
|
@ -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
|
@ -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
|
@ -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
|
||||
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
||||
|
Reference in New Issue
Block a user