coursemology-evaluator 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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