coursemology-evaluator 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env +3 -0
- data/.gitignore +22 -19
- data/.hound.yml +8 -0
- data/.idea/Coursemology Evaluator.iml +1 -76
- data/.rspec +1 -0
- data/.rubocop.unhound.yml +244 -0
- data/.rubocop.yml +26 -0
- data/.travis.yml +4 -2
- data/Gemfile.lock +114 -0
- data/Procfile +1 -0
- data/README.md +29 -2
- data/Rakefile +3 -3
- data/bin/evaluator +6 -0
- data/coursemology-evaluator.gemspec +18 -4
- data/lib/coursemology/evaluator.rb +32 -4
- data/lib/coursemology/evaluator/cli.rb +51 -0
- data/lib/coursemology/evaluator/client.rb +74 -0
- data/lib/coursemology/evaluator/docker_container.rb +58 -0
- data/lib/coursemology/evaluator/logging.rb +11 -0
- data/lib/coursemology/evaluator/logging/client_log_subscriber.rb +24 -0
- data/lib/coursemology/evaluator/logging/docker_log_subscriber.rb +17 -0
- data/lib/coursemology/evaluator/models.rb +6 -0
- data/lib/coursemology/evaluator/models/base.rb +49 -0
- data/lib/coursemology/evaluator/models/programming_evaluation.rb +66 -0
- data/lib/coursemology/evaluator/models/programming_evaluation/package.rb +11 -0
- data/lib/coursemology/evaluator/services.rb +5 -0
- data/lib/coursemology/evaluator/services/evaluate_programming_package_service.rb +150 -0
- data/lib/coursemology/evaluator/string_io.rb +13 -0
- data/lib/coursemology/evaluator/utils.rb +41 -0
- data/lib/coursemology/evaluator/version.rb +1 -1
- data/lib/coursemology/polyglot/extensions.rb +2 -0
- data/lib/coursemology/polyglot/extensions/language.rb +23 -0
- metadata +190 -12
data/Procfile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
evaluator: ruby bin/evaluator --host=$HOST --api-token=$API_TOKEN --api-user-email=$API_USER_EMAIL
|
data/README.md
CHANGED
@@ -1,2 +1,29 @@
|
|
1
|
-
# Coursemology Code Evaluator
|
2
|
-
|
1
|
+
# Coursemology Code Evaluator [](https://travis-ci.org/Coursemology/evaluator-slave)
|
2
|
+
[](https://codeclimate.com/github/Coursemology/evaluator-slave) [](https://coveralls.io/github/Coursemology/evaluator-slave?branch=master) [](https://hakiri.io/github/Coursemology/evaluator-slave/master) [](http://inch-ci.org/github/coursemology/evaluator-slave)
|
3
|
+
|
4
|
+
This is the evaluator program which will query Coursemology for pending evaluation jobs.
|
5
|
+
|
6
|
+
## Setting up the Evaluator Slave
|
7
|
+
|
8
|
+
### System Requirements
|
9
|
+
|
10
|
+
1. Ruby (>= 2.1.0)
|
11
|
+
2. Linux (tested on Ubuntu 14.04)
|
12
|
+
3. Docker (the user the evaluator runs as must be able to talk to the Docker Remote API endpoint)
|
13
|
+
|
14
|
+
### Getting Started
|
15
|
+
|
16
|
+
1. Install the gem
|
17
|
+
|
18
|
+
```sh
|
19
|
+
$ gem install coursemology-evaluator
|
20
|
+
```
|
21
|
+
|
22
|
+
2. Modify `.env` to suit your environment. Point to the host to your Coursemology instance, and
|
23
|
+
specify the API email and API key.
|
24
|
+
|
25
|
+
1. You might need to configure a new user on your Coursemology instance, enable token
|
26
|
+
authentication, and grant the `auto_grader` system/instance permission.
|
27
|
+
|
28
|
+
3. Start the evaluator using the Procfile. You can use [foreman](https://github.com/ddollar/foreman)
|
29
|
+
or any similar tool to generate system scripts for boot.
|
data/Rakefile
CHANGED
data/bin/evaluator
ADDED
@@ -13,11 +13,25 @@ Gem::Specification.new do |spec|
|
|
13
13
|
spec.summary = 'Coursemology programming package evaluator'
|
14
14
|
spec.description = 'Sets up a consistent environment for evaluating programming packages.'
|
15
15
|
spec.homepage = 'http://coursemology.org'
|
16
|
-
spec.files = `git ls-files -z`.split("\x0").
|
17
|
-
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").
|
17
|
+
reject { |f| f.match(/^(test|spec|features)\//) }
|
18
|
+
spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
|
18
19
|
spec.require_paths = ['lib']
|
19
20
|
|
20
|
-
spec.add_development_dependency 'bundler'
|
21
|
-
spec.add_development_dependency 'rake'
|
21
|
+
spec.add_development_dependency 'bundler'
|
22
|
+
spec.add_development_dependency 'rake'
|
22
23
|
spec.add_development_dependency 'rspec'
|
24
|
+
spec.add_development_dependency 'factory_girl'
|
25
|
+
spec.add_development_dependency 'simplecov'
|
26
|
+
spec.add_development_dependency 'coveralls'
|
27
|
+
spec.add_development_dependency 'codeclimate-test-reporter'
|
28
|
+
spec.add_development_dependency 'vcr'
|
29
|
+
|
30
|
+
spec.add_dependency 'activesupport', '~> 4.2.0'
|
31
|
+
spec.add_dependency 'flexirest', '~> 1.2'
|
32
|
+
spec.add_dependency 'faraday_middleware'
|
33
|
+
|
34
|
+
spec.add_dependency 'coursemology-polyglot', '>= 0.0.3'
|
35
|
+
spec.add_dependency 'docker-api', '>= 1.2.5'
|
36
|
+
spec.add_dependency 'rubyzip'
|
23
37
|
end
|
@@ -1,7 +1,35 @@
|
|
1
|
-
require
|
1
|
+
require 'active_support/all'
|
2
|
+
require 'flexirest'
|
3
|
+
require 'faraday_middleware'
|
4
|
+
require 'docker'
|
5
|
+
require 'zip'
|
6
|
+
Docker.validate_version!
|
2
7
|
|
3
|
-
|
4
|
-
|
5
|
-
|
8
|
+
require 'coursemology/polyglot'
|
9
|
+
require 'coursemology/polyglot/extensions'
|
10
|
+
require 'coursemology/evaluator/version'
|
11
|
+
|
12
|
+
module Coursemology::Evaluator
|
13
|
+
extend ActiveSupport::Autoload
|
14
|
+
|
15
|
+
autoload :Client
|
16
|
+
autoload :DockerContainer
|
17
|
+
autoload :CLI
|
18
|
+
autoload :Models
|
19
|
+
autoload :Services
|
20
|
+
autoload :StringIO
|
21
|
+
autoload :Utils
|
22
|
+
|
23
|
+
eager_autoload do
|
24
|
+
autoload :Logging
|
25
|
+
end
|
26
|
+
|
27
|
+
# The logger to use for the client.
|
28
|
+
mattr_reader(:logger) { ActiveSupport::Logger.new(STDOUT) }
|
29
|
+
|
30
|
+
def self.eager_load!
|
31
|
+
super
|
32
|
+
Coursemology::Polyglot.eager_load!
|
33
|
+
Logging.eager_load!
|
6
34
|
end
|
7
35
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
class Coursemology::Evaluator::CLI
|
4
|
+
Options = Struct.new(:host, :api_token, :api_user_email, :one_shot)
|
5
|
+
|
6
|
+
def self.start(argv)
|
7
|
+
new.start(argv)
|
8
|
+
end
|
9
|
+
|
10
|
+
def start(argv)
|
11
|
+
run(argv)
|
12
|
+
end
|
13
|
+
|
14
|
+
def run(argv)
|
15
|
+
options = optparse!(argv)
|
16
|
+
Coursemology::Evaluator::Client.initialize(options.host, options.api_user_email,
|
17
|
+
options.api_token)
|
18
|
+
Coursemology::Evaluator::Client.new(options.one_shot).run
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Parses the options specified on the command line.
|
24
|
+
#
|
25
|
+
# @param [Array<String>] argv The arguments specified on the command line.
|
26
|
+
# @return [Coursemology::Evaluator::CLI::Options]
|
27
|
+
def optparse!(argv) # rubocop:disable Metrics/MethodLength
|
28
|
+
options = Options.new
|
29
|
+
option_parser = OptionParser.new do |parser|
|
30
|
+
parser.banner = "Usage: #{parser.program_name} [options]"
|
31
|
+
parser.on('-hHOST', '--host=HOST', 'Coursemology host to connect to') do |host|
|
32
|
+
options.host = host
|
33
|
+
end
|
34
|
+
|
35
|
+
parser.on('-tTOKEN', '--api-token=TOKEN') do |token|
|
36
|
+
options.api_token = token
|
37
|
+
end
|
38
|
+
|
39
|
+
parser.on('-uUSER', '--api-user-email=USER') do |user|
|
40
|
+
options.api_user_email = user
|
41
|
+
end
|
42
|
+
|
43
|
+
parser.on('-o', '--one-shot') do
|
44
|
+
options.one_shot = true
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
option_parser.parse!(argv)
|
49
|
+
options
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
class Coursemology::Evaluator::Client
|
2
|
+
def self.initialize(host, api_user_email, api_token)
|
3
|
+
Coursemology::Evaluator::Models::Base.base_url = host
|
4
|
+
Coursemology::Evaluator::Models::Base.api_user_email = api_user_email
|
5
|
+
Coursemology::Evaluator::Models::Base.api_token = api_token
|
6
|
+
|
7
|
+
Coursemology::Evaluator::Models::Base.initialize
|
8
|
+
end
|
9
|
+
|
10
|
+
# @param [Boolean] one_shot If the client should only fire one request.
|
11
|
+
def initialize(one_shot = false)
|
12
|
+
@terminate = one_shot
|
13
|
+
end
|
14
|
+
|
15
|
+
def run
|
16
|
+
Signal.trap('SIGTERM', method(:on_sig_term))
|
17
|
+
|
18
|
+
loop do
|
19
|
+
allocate_evaluations
|
20
|
+
break if @terminate
|
21
|
+
|
22
|
+
# :nocov:
|
23
|
+
# This sleep might not be triggered in the specs, because interruptions to the thread is
|
24
|
+
# nondeterministically run by the OS scheduler.
|
25
|
+
sleep(1.minute)
|
26
|
+
# :nocov:
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Requests evaluations from the server.
|
33
|
+
def allocate_evaluations
|
34
|
+
evaluations =
|
35
|
+
ActiveSupport::Notifications.instrument('allocate.client.evaluator.coursemology') do
|
36
|
+
languages = Coursemology::Polyglot::Language.concrete_languages.map(&:display_name)
|
37
|
+
Coursemology::Evaluator::Models::ProgrammingEvaluation.allocate(language: languages)
|
38
|
+
end
|
39
|
+
|
40
|
+
on_allocate(evaluations)
|
41
|
+
rescue Flexirest::HTTPUnauthorisedClientException => e
|
42
|
+
ActiveSupport::Notifications.publish('allocate_fail.client.evaluator.coursemology', e: e)
|
43
|
+
end
|
44
|
+
|
45
|
+
# The callback for handling an array of allocated evaluations.
|
46
|
+
#
|
47
|
+
# @param [Array<Coursemology::Evaluator::Models::ProgrammingEvaluation>] evaluations The
|
48
|
+
# evaluations retrieved from the server.
|
49
|
+
def on_allocate(evaluations)
|
50
|
+
evaluations.each do |evaluation|
|
51
|
+
on_evaluation(evaluation)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# The callback for handling an evaluation.
|
56
|
+
#
|
57
|
+
# @param [Coursemology::Evaluator::Models::ProgrammingEvaluation] evaluation The evaluation
|
58
|
+
# retrieved from the server.
|
59
|
+
def on_evaluation(evaluation)
|
60
|
+
ActiveSupport::Notifications.instrument('evaluate.client.evaluator.coursemology',
|
61
|
+
evaluation: evaluation) do
|
62
|
+
evaluation.evaluate
|
63
|
+
end
|
64
|
+
|
65
|
+
ActiveSupport::Notifications.instrument('save.client.evaluator.coursemology') do
|
66
|
+
evaluation.save
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# The callback for handling SIGTERM sent to the process.
|
71
|
+
def on_sig_term
|
72
|
+
@terminate = true
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
class Coursemology::Evaluator::DockerContainer < Docker::Container
|
2
|
+
class << self
|
3
|
+
def create(image, argv: nil)
|
4
|
+
pull_image(image)
|
5
|
+
|
6
|
+
ActiveSupport::Notifications.instrument('create.docker.evaluator.coursemology',
|
7
|
+
image: image) do |payload|
|
8
|
+
options = { 'Image' => image }
|
9
|
+
options['Cmd'] = argv if argv && !argv.empty?
|
10
|
+
|
11
|
+
payload[:container] = super(options)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def pull_image(image)
|
18
|
+
ActiveSupport::Notifications.instrument('pull.docker.evaluator.coursemology',
|
19
|
+
image: image) do
|
20
|
+
Docker::Image.create('fromImage' => image)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Waits for the container to exit the Running state.
|
26
|
+
#
|
27
|
+
# This will time out for long running operations, so keep retrying until we return.
|
28
|
+
#
|
29
|
+
# @param [Fixnum|nil] time The amount of time to wait.
|
30
|
+
# @return [Fixnum] The exit code of the container.
|
31
|
+
def wait(time = nil)
|
32
|
+
container_state = info
|
33
|
+
while container_state.fetch('State', {}).fetch('Running', true)
|
34
|
+
super
|
35
|
+
refresh!
|
36
|
+
container_state = info
|
37
|
+
end
|
38
|
+
|
39
|
+
container_state['State']['ExitCode']
|
40
|
+
rescue Docker::Error::TimeoutError
|
41
|
+
retry
|
42
|
+
end
|
43
|
+
|
44
|
+
# Gets the exit code of the container.
|
45
|
+
#
|
46
|
+
# @return [Fixnum] The exit code of the container, if +wait+ was called before.
|
47
|
+
# @return [nil] If the container is still running, or +wait+ was not called.
|
48
|
+
def exit_code
|
49
|
+
info.fetch('State', {})['ExitCode']
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete
|
53
|
+
ActiveSupport::Notifications.instrument('destroy.docker.evaluator.coursemology',
|
54
|
+
container: id) do
|
55
|
+
super
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Coursemology::Evaluator::Logging
|
2
|
+
extend ActiveSupport::Autoload
|
3
|
+
|
4
|
+
eager_autoload do
|
5
|
+
autoload :ClientLogSubscriber
|
6
|
+
autoload :DockerLogSubscriber
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# Define +Rails+ to trick ActiveSupport into logging to our logger.
|
11
|
+
Rails = Coursemology::Evaluator
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class Coursemology::Evaluator::Logging::ClientLogSubscriber < ActiveSupport::LogSubscriber
|
2
|
+
def publish(name, *args)
|
3
|
+
send(name.split('.').first, *args)
|
4
|
+
end
|
5
|
+
|
6
|
+
def allocate(event)
|
7
|
+
info color("Client: Allocate (#{event.duration.round(1)}ms)", MAGENTA)
|
8
|
+
end
|
9
|
+
|
10
|
+
def allocate_fail(e:)
|
11
|
+
error color("Client: Allocate failed: #{e.message}", RED)
|
12
|
+
end
|
13
|
+
|
14
|
+
def evaluate(event)
|
15
|
+
info "#{color("Client: Evaluate (#{event.duration.round(1)}ms)", CYAN)} "\
|
16
|
+
"#{event.payload[:evaluation].language.class.display_name}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def save(event)
|
20
|
+
info color("Client: Save (#{event.duration.round(1)}ms)", GREEN)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
Coursemology::Evaluator::Logging::ClientLogSubscriber.attach_to(:'client.evaluator.coursemology')
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class Coursemology::Evaluator::Logging::DockerLogSubscriber < ActiveSupport::LogSubscriber
|
2
|
+
def pull(event)
|
3
|
+
info "#{color("Docker Pull (#{event.duration.round(1)}ms)", GREEN)} #{event.payload[:image]}"
|
4
|
+
end
|
5
|
+
|
6
|
+
def create(event)
|
7
|
+
info "#{color("Docker Create (#{event.duration.round(1)}ms)", MAGENTA)} "\
|
8
|
+
"#{event.payload[:image]} => #{event.payload[:container].id}"
|
9
|
+
end
|
10
|
+
|
11
|
+
def destroy(event)
|
12
|
+
info "#{color("Docker Destroy (#{event.duration.round(1)}ms)", CYAN)} "\
|
13
|
+
"#{event.payload[:container]}"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
Coursemology::Evaluator::Logging::DockerLogSubscriber.attach_to(:'docker.evaluator.coursemology')
|
@@ -0,0 +1,49 @@
|
|
1
|
+
class Coursemology::Evaluator::Models::Base < Flexirest::Base
|
2
|
+
class << self
|
3
|
+
attr_accessor :api_user_email
|
4
|
+
attr_accessor :api_token
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
Flexirest::Base.perform_caching = false
|
8
|
+
default_config = Flexirest::Base.faraday_config
|
9
|
+
Flexirest::Base.faraday_config do |faraday|
|
10
|
+
# +follow_redirects+ must be added before declaring the adapter. See faraday_middleware#32,
|
11
|
+
# last comment.
|
12
|
+
faraday.response :follow_redirects
|
13
|
+
|
14
|
+
default_config.call(faraday)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
verbose!
|
20
|
+
before_request :add_authentication
|
21
|
+
|
22
|
+
# Sets the key of the model. This is the key that all attributes are nested under, the same as
|
23
|
+
# the +require+ directive in the controller of the web application.
|
24
|
+
#
|
25
|
+
# @param [String] key The key to prefix all attributes with.
|
26
|
+
def self.model_key(key)
|
27
|
+
before_request do |name, param|
|
28
|
+
fix_put_parameters(key, name, param) if [:post, :patch, :put].include?(param.method[:method])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
private_class_method :model_key
|
32
|
+
|
33
|
+
# Fixes the request parameters when executing a POST, PATCH or PUT.
|
34
|
+
#
|
35
|
+
# @param [String] key The key to prefix all attributes with.
|
36
|
+
# @param [Request] param The request parameter to prepend the key with.
|
37
|
+
def self.fix_put_parameters(key, _, param)
|
38
|
+
param.post_params = { key => param.post_params } unless param.post_params.empty?
|
39
|
+
end
|
40
|
+
private_class_method :fix_put_parameters
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
# Adds the authentication email and token to the request.
|
45
|
+
def add_authentication(_, request)
|
46
|
+
request.headers['X-User-Email'] = self.class.api_user_email
|
47
|
+
request.headers['X-User-Token'] = self.class.api_token
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
class Coursemology::Evaluator::Models::ProgrammingEvaluation < Coursemology::Evaluator::Models::Base
|
2
|
+
extend ActiveSupport::Autoload
|
3
|
+
autoload :Package
|
4
|
+
|
5
|
+
request_body_type :json
|
6
|
+
model_key :programming_evaluation
|
7
|
+
|
8
|
+
get :find, 'courses/assessment/programming_evaluations/:id'.freeze
|
9
|
+
post :allocate, 'courses/assessment/programming_evaluations/allocate'.freeze
|
10
|
+
put :save, 'courses/assessment/programming_evaluations/:id/result'.freeze
|
11
|
+
|
12
|
+
# Gets the language for the programming evaluation.
|
13
|
+
#
|
14
|
+
# @return [nil] If the language does not exist.
|
15
|
+
# @return [Coursemology::Polyglot::Language] The language that the evaluation uses.
|
16
|
+
def language
|
17
|
+
Coursemology::Polyglot::Language.find_by(type: super)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Sets the language for the programming evaluation.
|
21
|
+
#
|
22
|
+
# @param [String|nil|Coursemology::Polyglot::Language] language The language to set. If this is
|
23
|
+
# a string, it is assumed to be the class name of the language.
|
24
|
+
def language=(language)
|
25
|
+
return super(language) if language.nil? || language.is_a?(String)
|
26
|
+
|
27
|
+
fail ArgumentError unless language.is_a?(Coursemology::Polyglot::Language)
|
28
|
+
super(language.class.name)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Obtains the package.
|
32
|
+
#
|
33
|
+
# @return [Coursemology::Evaluator::Models::ProgrammingEvaluation::Package]
|
34
|
+
def package
|
35
|
+
@package ||= begin
|
36
|
+
body = plain_request('courses/assessment/programming_evaluations/:id/package', id: id)
|
37
|
+
Package.new(Coursemology::Evaluator::StringIO.new(body))
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Evaluates the package, and stores the result in this record.
|
42
|
+
#
|
43
|
+
# Call {Coursemology::Evaluator::Models::ProgrammingEvaluation#save} to save the record to the
|
44
|
+
# server.
|
45
|
+
def evaluate
|
46
|
+
result = Coursemology::Evaluator::Services::EvaluateProgrammingPackageService.
|
47
|
+
execute(self)
|
48
|
+
self.stdout = result.stdout
|
49
|
+
self.stderr = result.stderr
|
50
|
+
self.test_report = result.test_report
|
51
|
+
self.exit_code = result.exit_code
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Performs a plain request.
|
57
|
+
#
|
58
|
+
# @param [String] url The URL to request.
|
59
|
+
# @param [Hash] params The parameter to be part of the request.
|
60
|
+
# @return [String] The response body.
|
61
|
+
def plain_request(url, params = {})
|
62
|
+
request = Flexirest::Request.new({ url: url, method: :get, options: { plain: true } },
|
63
|
+
self.class)
|
64
|
+
request.call(params)
|
65
|
+
end
|
66
|
+
end
|