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.
@@ -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
- This is the evaluator program which will query Coursemology for pending evaluation jobs.
1
+ # Coursemology Code Evaluator [![Build Status](https://travis-ci.org/Coursemology/evaluator-slave.svg?branch=master)](https://travis-ci.org/Coursemology/evaluator-slave)
2
+ [![Code Climate](https://codeclimate.com/github/Coursemology/evaluator-slave/badges/gpa.svg)](https://codeclimate.com/github/Coursemology/evaluator-slave) [![Coverage Status](https://coveralls.io/repos/Coursemology/evaluator-slave/badge.svg?branch=master&service=github)](https://coveralls.io/github/Coursemology/evaluator-slave?branch=master) [![Security](https://hakiri.io/github/Coursemology/evaluator-slave/master.svg)](https://hakiri.io/github/Coursemology/evaluator-slave/master) [![Inline docs](http://inch-ci.org/github/coursemology/evaluator-slave.svg?branch=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
@@ -1,6 +1,6 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
3
 
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
- task :default => :spec
6
+ task default: :spec
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ require 'bundler/setup'
3
+ require 'coursemology/evaluator'
4
+
5
+ Coursemology::Evaluator.eager_load!
6
+ Coursemology::Evaluator::CLI.start(ARGV)
@@ -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").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
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', '~> 1.10'
21
- spec.add_development_dependency 'rake', '~> 10.0'
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 "coursemology/evaluator/version"
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
- module Coursemology
4
- module Evaluator
5
- # Your code goes here...
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,6 @@
1
+ module Coursemology::Evaluator::Models
2
+ extend ActiveSupport::Autoload
3
+
4
+ autoload :Base
5
+ autoload :ProgrammingEvaluation
6
+ end
@@ -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