transferred Code Ocean from original repository to GitHub
This commit is contained in:
26
lib/assessor.rb
Normal file
26
lib/assessor.rb
Normal file
@ -0,0 +1,26 @@
|
||||
class Assessor
|
||||
MAXIMUM_SCORE = 1
|
||||
|
||||
def assess(output)
|
||||
test_outcome = @testing_framework_adapter.test_outcome(output)
|
||||
test_outcome.merge(score: calculate_score(test_outcome))
|
||||
rescue Exception
|
||||
{score: 0}
|
||||
end
|
||||
|
||||
def calculate_score(test_outcome)
|
||||
(test_outcome[:passed].to_f / test_outcome[:count].to_f)
|
||||
end
|
||||
private :calculate_score
|
||||
|
||||
def initialize(options = {})
|
||||
if options[:execution_environment].testing_framework?
|
||||
@testing_framework_adapter = Kernel.const_get(options[:execution_environment].testing_framework).new
|
||||
else
|
||||
raise Error.new('No testing framework adapter set!')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Assessor::Error < RuntimeError
|
||||
end
|
0
lib/assets/.keep
Normal file
0
lib/assets/.keep
Normal file
41
lib/assets/javascripts/flash.js
Normal file
41
lib/assets/javascripts/flash.js
Normal file
@ -0,0 +1,41 @@
|
||||
(function() {
|
||||
var DURATION = 10000;
|
||||
var SEVERITIES = ['danger', 'info', 'success', 'warning'];
|
||||
|
||||
var buildFlash = function(options) {
|
||||
if (options.text) {
|
||||
var container = options.container;
|
||||
var html = '';
|
||||
if (options.icon) {
|
||||
html += '<i class="' + options.icon.join(' ') + '"> ';
|
||||
}
|
||||
html += options.text;
|
||||
container.html(html);
|
||||
showFlashes();
|
||||
}
|
||||
};
|
||||
|
||||
var generateMethods = function() {
|
||||
$.flash = {};
|
||||
$.each(SEVERITIES, function(index, severity) {
|
||||
$.flash[severity] = function(options) {
|
||||
buildFlash($.extend(options, {
|
||||
container: $('#flash-' + severity)
|
||||
}));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
var showFlashes = function() {
|
||||
$('.flash').each(function() {
|
||||
if ($(this).html() !== '') {
|
||||
$(this).slideDown().delay(DURATION).slideUp(function() {
|
||||
$(this).html('');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
generateMethods();
|
||||
$(showFlashes);
|
||||
})();
|
3
lib/assets/stylesheets/flash.css.scss
Normal file
3
lib/assets/stylesheets/flash.css.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.flash {
|
||||
display: none;
|
||||
}
|
14
lib/code_ocean/config.rb
Normal file
14
lib/code_ocean/config.rb
Normal file
@ -0,0 +1,14 @@
|
||||
module CodeOcean
|
||||
class Config
|
||||
def initialize(filename)
|
||||
@filename = filename
|
||||
end
|
||||
|
||||
def read
|
||||
path = Rails.root.join('config', "#{@filename}.yml")
|
||||
if File.exists?(path)
|
||||
YAML.load_file(path)[Rails.env].symbolize_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
175
lib/docker_client.rb
Normal file
175
lib/docker_client.rb
Normal file
@ -0,0 +1,175 @@
|
||||
class DockerClient
|
||||
CONFIG_PATH = Rails.root.join('config', 'docker.yml.erb')
|
||||
CONTAINER_WORKSPACE_PATH = '/workspace'
|
||||
LOCAL_WORKSPACE_ROOT = Rails.root.join('tmp', 'files', Rails.env)
|
||||
|
||||
attr_reader :assigned_ports
|
||||
attr_reader :container_id
|
||||
|
||||
def bound_folders
|
||||
@submission ? ["#{remote_workspace_path}:#{CONTAINER_WORKSPACE_PATH}"] : []
|
||||
end
|
||||
private :bound_folders
|
||||
|
||||
def self.check_availability!
|
||||
initialize_environment
|
||||
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 clean_workspace
|
||||
FileUtils.rm_rf(local_workspace_path)
|
||||
end
|
||||
private :clean_workspace
|
||||
|
||||
def command_substitutions(filename)
|
||||
{class_name: File.basename(filename, File.extname(filename)).camelize, filename: filename}
|
||||
end
|
||||
private :command_substitutions
|
||||
|
||||
def self.config
|
||||
YAML.load(ERB.new(File.new(CONFIG_PATH, 'r').read).result)[Rails.env].with_indifferent_access
|
||||
end
|
||||
|
||||
def copy_file_to_workspace(options = {})
|
||||
FileUtils.cp(options[:file].native_file.path, File.join(local_workspace_path, options[:file].path || '', options[:file].name_with_extension))
|
||||
end
|
||||
|
||||
def create_container(options = {})
|
||||
Docker::Container.create('Cmd' => options[:command], 'Image' => @image.info['RepoTags'].first)
|
||||
end
|
||||
private :create_container
|
||||
|
||||
def create_workspace
|
||||
@submission.collect_files.each do |file|
|
||||
FileUtils.mkdir_p(File.join(local_workspace_path, file.path || ''))
|
||||
if file.file_type.binary?
|
||||
copy_file_to_workspace(file: file)
|
||||
else
|
||||
create_workspace_file(file: file)
|
||||
end
|
||||
end
|
||||
end
|
||||
private :create_workspace
|
||||
|
||||
def create_workspace_file(options = {})
|
||||
file = File.new(File.join(local_workspace_path, options[:file].path || '', options[:file].name_with_extension), 'w')
|
||||
file.write(options[:file].content)
|
||||
file.close
|
||||
end
|
||||
private :create_workspace_file
|
||||
|
||||
def self.destroy_container(container)
|
||||
container.stop.kill
|
||||
if container.json['HostConfig']['PortBindings']
|
||||
container.json['HostConfig']['PortBindings'].values.each do |configuration|
|
||||
port = configuration.first['HostPort'].to_i
|
||||
PortPool.release(port)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def execute_command(command, &block)
|
||||
container = create_container(command: ['bash', '-c', command])
|
||||
@container_id = container.id
|
||||
start_container(container, &block)
|
||||
end
|
||||
|
||||
def execute_in_workspace(submission, &block)
|
||||
@submission = submission
|
||||
create_workspace
|
||||
block.call
|
||||
ensure
|
||||
clean_workspace if @submission
|
||||
end
|
||||
private :execute_in_workspace
|
||||
|
||||
def execute_run_command(submission, filename, &block)
|
||||
execute_in_workspace(submission) do
|
||||
execute_command(@execution_environment.run_command % command_substitutions(filename), &block)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_test_command(submission, filename)
|
||||
execute_in_workspace(submission) do
|
||||
execute_command(@execution_environment.test_command % command_substitutions(filename))
|
||||
end
|
||||
end
|
||||
|
||||
def find_image_by_tag(tag)
|
||||
Docker::Image.all.detect { |image| image.info['RepoTags'].flatten.include?(tag) }
|
||||
end
|
||||
private :find_image_by_tag
|
||||
|
||||
def self.image_tags
|
||||
check_availability!
|
||||
Docker::Image.all.map { |image| image.info['RepoTags'] }.flatten.reject { |tag| tag.include?('<none>') }
|
||||
end
|
||||
|
||||
def initialize(options = {})
|
||||
self.class.check_availability!
|
||||
@execution_environment = options[:execution_environment]
|
||||
@user = options[:user]
|
||||
@image = find_image_by_tag(@execution_environment.docker_image)
|
||||
raise Error.new("Cannot find image #{@execution_environment.docker_image}!") unless @image
|
||||
end
|
||||
|
||||
def self.initialize_environment
|
||||
unless config[:connection_timeout] && config[:workspace_root]
|
||||
raise Error.new('Docker configuration missing!')
|
||||
end
|
||||
Docker.url = config[:host] if config[:host]
|
||||
end
|
||||
|
||||
def local_workspace_path
|
||||
File.join(LOCAL_WORKSPACE_ROOT, @submission.id.to_s)
|
||||
end
|
||||
private :local_workspace_path
|
||||
|
||||
def mapped_ports
|
||||
@assigned_ports = []
|
||||
(@execution_environment.exposed_ports || '').gsub(/\s/, '').split(',').map do |port|
|
||||
@assigned_ports << PortPool.available_port
|
||||
["#{port}/tcp", [{'HostPort' => @assigned_ports.last.to_s}]]
|
||||
end.to_h
|
||||
end
|
||||
private :mapped_ports
|
||||
|
||||
def self.pull(docker_image)
|
||||
`docker pull #{docker_image}` if docker_image
|
||||
end
|
||||
|
||||
def remote_workspace_path
|
||||
File.join(self.class.config[:workspace_root], @submission.id.to_s)
|
||||
end
|
||||
private :remote_workspace_path
|
||||
|
||||
def start_container(container, &block)
|
||||
Timeout::timeout(@execution_environment.permitted_execution_time) do
|
||||
container.start('Binds' => bound_folders, 'PortBindings' => mapped_ports)
|
||||
container.wait(@execution_environment.permitted_execution_time)
|
||||
stderr = []
|
||||
stdout = []
|
||||
container.streaming_logs(stderr: true, stdout: true) do |stream, chunk|
|
||||
block.call(stream, chunk) if block_given?
|
||||
if stream == :stderr
|
||||
stderr.push(chunk)
|
||||
else
|
||||
stdout.push(chunk)
|
||||
end
|
||||
end
|
||||
{status: :ok, stderr: stderr.join, stdout: stdout.join}
|
||||
end
|
||||
rescue Docker::Error::TimeoutError, Timeout::Error
|
||||
{status: :timeout}
|
||||
ensure
|
||||
self.class.destroy_container(container)
|
||||
end
|
||||
private :start_container
|
||||
end
|
||||
|
||||
class DockerClient::Error < RuntimeError
|
||||
end
|
||||
|
||||
DockerClient.initialize_environment
|
75
lib/file_tree.rb
Normal file
75
lib/file_tree.rb
Normal file
@ -0,0 +1,75 @@
|
||||
class FileTree < Tree::TreeNode
|
||||
def file_icon(file)
|
||||
if file.file_type.audio?
|
||||
'fa fa-file-audio-o'
|
||||
elsif file.file_type.image?
|
||||
'fa fa-file-image-o'
|
||||
elsif file.file_type.video?
|
||||
'fa fa-file-video-o'
|
||||
elsif file.read_only?
|
||||
'fa fa-lock'
|
||||
elsif file.file_type.executable?
|
||||
'fa fa-file-code-o'
|
||||
elsif file.file_type.renderable?
|
||||
'fa fa-file-text-o'
|
||||
else
|
||||
'fa fa-file-o'
|
||||
end
|
||||
end
|
||||
private :file_icon
|
||||
|
||||
def folder_icon
|
||||
'fa fa-folder-o'
|
||||
end
|
||||
private :folder_icon
|
||||
|
||||
def initialize(files)
|
||||
super(root_label)
|
||||
files.each do |file|
|
||||
parent = self
|
||||
(file.path || '').split('/').each do |segment|
|
||||
node = parent.children.detect { |child| child.name == segment } || parent.add(Tree::TreeNode.new(segment))
|
||||
parent = node
|
||||
end
|
||||
parent.add(Tree::TreeNode.new(file.name_with_extension, file))
|
||||
end
|
||||
end
|
||||
|
||||
def map_to_js_tree(node)
|
||||
{
|
||||
children: node.children.map { |child| map_to_js_tree(child) },
|
||||
icon: node_icon(node),
|
||||
id: node.content.try(:ancestor_id),
|
||||
state: {
|
||||
disabled: !node.is_leaf?,
|
||||
opened: !node.is_leaf?
|
||||
},
|
||||
text: node.name
|
||||
}
|
||||
end
|
||||
private :map_to_js_tree
|
||||
|
||||
def node_icon(node)
|
||||
if node.is_root?
|
||||
folder_icon
|
||||
elsif node.is_leaf?
|
||||
file_icon(node.content)
|
||||
else
|
||||
folder_icon
|
||||
end
|
||||
end
|
||||
private :node_icon
|
||||
|
||||
def root_label
|
||||
I18n.t('exercises.editor_file_tree.file_root')
|
||||
end
|
||||
private :root_label
|
||||
|
||||
def to_js_tree
|
||||
{
|
||||
core: {
|
||||
data: map_to_js_tree(self)
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
end
|
30
lib/generators/testing_framework_adapter_generator.rb
Normal file
30
lib/generators/testing_framework_adapter_generator.rb
Normal file
@ -0,0 +1,30 @@
|
||||
require 'rails/generators'
|
||||
|
||||
class TestingFrameworkAdapterGenerator < Rails::Generators::NamedBase
|
||||
ADAPTER_PATH = ->(name) { Rails.root.join('lib', "#{name.underscore}_adapter.rb") }
|
||||
SPEC_PATH = ->(name) { Rails.root.join('spec', 'lib', "#{name.underscore}_adapter_spec.rb") }
|
||||
|
||||
def create_testing_framework_adapter
|
||||
create_file ADAPTER_PATH.call(file_name), <<-code
|
||||
class #{file_name.camelize}Adapter < TestingFrameworkAdapter
|
||||
def self.framework_name
|
||||
'#{file_name.camelize}'
|
||||
end
|
||||
|
||||
def parse_output(output)
|
||||
end
|
||||
end
|
||||
code
|
||||
end
|
||||
|
||||
def create_spec
|
||||
create_file SPEC_PATH.call(file_name), <<-code
|
||||
require 'rails_helper'
|
||||
|
||||
describe #{file_name.camelize}Adapter do
|
||||
describe '#parse_output' do
|
||||
end
|
||||
end
|
||||
code
|
||||
end
|
||||
end
|
19
lib/junit_adapter.rb
Normal file
19
lib/junit_adapter.rb
Normal file
@ -0,0 +1,19 @@
|
||||
class JunitAdapter < TestingFrameworkAdapter
|
||||
COUNT_REGEXP = /Tests run: (\d+)/
|
||||
FAILURES_REGEXP = /Failures: (\d+)/
|
||||
SUCCESS_REGEXP = /OK \((\d+) test[s]?\)/
|
||||
|
||||
def self.framework_name
|
||||
'JUnit'
|
||||
end
|
||||
|
||||
def parse_output(output)
|
||||
if SUCCESS_REGEXP.match(output[:stdout])
|
||||
{count: $1.to_i, passed: $1.to_i}
|
||||
else
|
||||
count = COUNT_REGEXP.match(output[:stdout]).try(:captures).try(:first).try(:to_i) || 0
|
||||
failed = FAILURES_REGEXP.match(output[:stdout]).try(:captures).try(:first).try(:to_i) || 0
|
||||
{count: count, failed: failed}
|
||||
end
|
||||
end
|
||||
end
|
17
lib/nonce_store.rb
Normal file
17
lib/nonce_store.rb
Normal file
@ -0,0 +1,17 @@
|
||||
class NonceStore
|
||||
def self.build_cache_key(nonce)
|
||||
"lti_nonce_#{nonce}"
|
||||
end
|
||||
|
||||
def self.add(nonce)
|
||||
Rails.cache.write(build_cache_key(nonce), Time.now, expires_in: Lti::MAXIMUM_SESSION_AGE)
|
||||
end
|
||||
|
||||
def self.delete(nonce)
|
||||
Rails.cache.delete(build_cache_key(nonce))
|
||||
end
|
||||
|
||||
def self.has?(nonce)
|
||||
Rails.cache.exist?(build_cache_key(nonce))
|
||||
end
|
||||
end
|
16
lib/port_pool.rb
Normal file
16
lib/port_pool.rb
Normal file
@ -0,0 +1,16 @@
|
||||
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.include?(port)
|
||||
end
|
||||
end
|
15
lib/py_unit_adapter.rb
Normal file
15
lib/py_unit_adapter.rb
Normal file
@ -0,0 +1,15 @@
|
||||
class PyUnitAdapter < TestingFrameworkAdapter
|
||||
COUNT_REGEXP = /Ran (\d+) tests/
|
||||
FAILURES_REGEXP = /FAILED \(failures=(\d+)\)/
|
||||
|
||||
def self.framework_name
|
||||
'PyUnit'
|
||||
end
|
||||
|
||||
def parse_output(output)
|
||||
count = COUNT_REGEXP.match(output[:stderr]).captures.first.to_i
|
||||
matches = FAILURES_REGEXP.match(output[:stderr])
|
||||
failed = matches ? matches.captures.try(:first).to_i : 0
|
||||
{count: count, failed: failed}
|
||||
end
|
||||
end
|
14
lib/rspec_adapter.rb
Normal file
14
lib/rspec_adapter.rb
Normal file
@ -0,0 +1,14 @@
|
||||
class RspecAdapter < TestingFrameworkAdapter
|
||||
REGEXP = /(\d+) examples?, (\d+) failures?/
|
||||
|
||||
def self.framework_name
|
||||
'RSpec 3'
|
||||
end
|
||||
|
||||
def parse_output(output)
|
||||
captures = REGEXP.match(output[:stdout]).captures.map(&:to_i)
|
||||
count = captures.first
|
||||
failed = captures.second
|
||||
{count: count, failed: failed}
|
||||
end
|
||||
end
|
12
lib/seeds_helper.rb
Normal file
12
lib/seeds_helper.rb
Normal file
@ -0,0 +1,12 @@
|
||||
module SeedsHelper
|
||||
def self.read_seed_file(filename)
|
||||
file = File.new(seed_file_path(filename), 'r')
|
||||
content = file.read
|
||||
file.close
|
||||
content
|
||||
end
|
||||
|
||||
def self.seed_file_path(filename)
|
||||
Rails.root.join('db', 'seeds', filename)
|
||||
end
|
||||
end
|
16
lib/sql_result_set_comparator_adapter.rb
Normal file
16
lib/sql_result_set_comparator_adapter.rb
Normal file
@ -0,0 +1,16 @@
|
||||
class SqlResultSetComparatorAdapter < TestingFrameworkAdapter
|
||||
MISSING_TUPLES_REGEXP = /Missing tuples: \[\]/
|
||||
UNEXPECTED_TUPLES_REGEXP = /Unexpected tuples: \[\]/
|
||||
|
||||
def self.framework_name
|
||||
'SqlResultSetComparator'
|
||||
end
|
||||
|
||||
def parse_output(output)
|
||||
if MISSING_TUPLES_REGEXP.match(output[:stdout]) && UNEXPECTED_TUPLES_REGEXP.match(output[:stdout])
|
||||
{count: 1, passed: 1}
|
||||
else
|
||||
{count: 1, failed: 1}
|
||||
end
|
||||
end
|
||||
end
|
0
lib/tasks/.keep
Normal file
0
lib/tasks/.keep
Normal file
14
lib/tasks/docker.rake
Normal file
14
lib/tasks/docker.rake
Normal file
@ -0,0 +1,14 @@
|
||||
namespace :docker do
|
||||
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
|
25
lib/testing_framework_adapter.rb
Normal file
25
lib/testing_framework_adapter.rb
Normal file
@ -0,0 +1,25 @@
|
||||
class TestingFrameworkAdapter
|
||||
def augment_output(options = {})
|
||||
if !options[:count]
|
||||
options.merge(count: options[:failed] + options[:passed])
|
||||
elsif !options[:failed]
|
||||
options.merge(failed: options[:count] - options[:passed])
|
||||
elsif !options[:passed]
|
||||
options.merge(passed: options[:count] - options[:failed])
|
||||
end
|
||||
end
|
||||
private :augment_output
|
||||
|
||||
def self.framework_name
|
||||
self.name
|
||||
end
|
||||
|
||||
def parse_output(output)
|
||||
raise NotImplementedError.new("#{self.class} should implement #parse_output!")
|
||||
end
|
||||
private :parse_output
|
||||
|
||||
def test_outcome(output)
|
||||
augment_output(parse_output(output))
|
||||
end
|
||||
end
|
20
lib/whistleblower.rb
Normal file
20
lib/whistleblower.rb
Normal file
@ -0,0 +1,20 @@
|
||||
class Whistleblower
|
||||
PLACEHOLDER_REGEXP = /\$(\d)/
|
||||
|
||||
def find_hint(stderr)
|
||||
Hint.where(execution_environment_id: @execution_environment.id).detect do |hint|
|
||||
@matches = Regexp.new(hint.regular_expression).match(stderr)
|
||||
end
|
||||
end
|
||||
private :find_hint
|
||||
|
||||
def generate_hint(stderr)
|
||||
if hint = find_hint(stderr)
|
||||
hint.message.gsub(PLACEHOLDER_REGEXP) { @matches[$1.to_i] }
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(options = {})
|
||||
@execution_environment = options[:execution_environment]
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user