Files
codeocean/app/controllers/execution_environments_controller.rb
Sebastian Serth 8fc5123bae Exclusively lock Runners during code executions
Previously, the same runner could be used multiple times with different submissions simultaneously. This, however, yielded errors, for example when one submission time oud (causing the running to be deleted) while another submission was still executed.

Admin actions, such as the shell, can be still executed regardless of any other code execution.

Fixes CODEOCEAN-HG
Fixes openHPI/poseidon#423
2023-10-31 12:35:24 +01:00

258 lines
9.6 KiB
Ruby

# frozen_string_literal: true
class ExecutionEnvironmentsController < ApplicationController
include CommonBehavior
include FileConversion
include TimeHelper
before_action :set_docker_images, only: %i[create edit new update]
before_action :set_execution_environment, only: MEMBER_ACTIONS + %i[execute_command shell list_files statistics sync_to_runner_management]
before_action :set_testing_framework_adapters, only: %i[create edit new update]
def authorize!
authorize(@execution_environment || @execution_environments)
end
private :authorize!
def index
@execution_environments = ExecutionEnvironment.includes(:user).order(:name).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
def show
if @execution_environment.testing_framework?
@testing_framework_adapter = TestingFrameworkAdapter.descendants.find {|klass| klass.name == @execution_environment.testing_framework }
end
end
def new
@execution_environment = ExecutionEnvironment.new
authorize!
end
def execute_command
runner = Runner.for(current_user, @execution_environment)
@privileged_execution = ActiveModel::Type::Boolean.new.cast(params[:sudo]) || @execution_environment.privileged_execution
output = runner.execute_command(params[:command], privileged_execution: @privileged_execution, raise_exception: false, exclusive: false)
render json: output.except(:messages)
end
def list_files
runner = Runner.for(current_user, @execution_environment)
@privileged_execution = ActiveModel::Type::Boolean.new.cast(params[:sudo]) || @execution_environment.privileged_execution
begin
files = runner.retrieve_files(path: params[:path], recursive: false, privileged_execution: @privileged_execution, exclusive: false)
downloadable_files, additional_directories = convert_files_json_to_files files
js_tree = FileTree.new(downloadable_files, additional_directories, force_closed: true).to_js_tree
render json: js_tree[:core][:data]
rescue Runner::Error::RunnerNotFound, Runner::Error::WorkspaceError, Runner::Error::RunnerInUse
render json: []
end
end
def working_time_query
"
SELECT exercise_id, avg(working_time) as average_time, stddev_samp(extract('epoch' from working_time)) * interval '1 second' as stddev_time
FROM
(
SELECT contributor_id,
exercise_id,
sum(working_time_new) AS working_time
FROM
(SELECT contributor_id,
exercise_id,
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT contributor_id,
exercise_id,
id,
(created_at - lag(created_at) over (PARTITION BY contributor_id, exercise_id
ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id IN (SELECT ID FROM exercises WHERE #{ExecutionEnvironment.sanitize_sql(['execution_environment_id = ?', @execution_environment.id])})
GROUP BY exercise_id, contributor_id, id) AS foo) AS bar
GROUP BY contributor_id, exercise_id
) AS baz GROUP BY exercise_id;
"
end
def contributor_query
"
SELECT
id AS exercise_id,
COUNT(DISTINCT contributor_id) AS contributors,
AVG(score) AS average_score,
MAX(score) AS maximum_score,
stddev_samp(score) as stddev_score,
CASE
WHEN MAX(score)=0 THEN 0
ELSE 100 / MAX(score) * AVG(score)
END AS percent_correct,
SUM(submission_count) / COUNT(DISTINCT contributor_id) AS average_submission_count
FROM
(SELECT e.id,
s.contributor_id,
MAX(s.score) AS score,
COUNT(s.id) AS submission_count
FROM submissions s
JOIN exercises e ON e.id = s.exercise_id
WHERE #{ExecutionEnvironment.sanitize_sql(['e.execution_environment_id = ?', @execution_environment.id])}
GROUP BY e.id,
s.contributor_id) AS inner_query
GROUP BY id;
"
end
def statistics
working_time_statistics = {}
contributor_statistics = {}
ApplicationRecord.connection.exec_query(working_time_query).each do |tuple|
tuple = tuple.merge({
'average_time' => format_time_difference(tuple['average_time']),
'stddev_time' => format_time_difference(tuple['stddev_time']),
})
working_time_statistics[tuple['exercise_id'].to_i] = tuple
end
ApplicationRecord.connection.exec_query(contributor_query).each do |tuple|
contributor_statistics[tuple['exercise_id'].to_i] = tuple
end
render locals: {
working_time_statistics:,
contributor_statistics:,
}
end
def execution_environment_params
if params[:execution_environment].present?
exposed_ports = if params[:execution_environment][:exposed_ports_list]
# Transform the `exposed_ports_list` to `exposed_ports` array
params[:execution_environment].delete(:exposed_ports_list).scan(/\d+/)
else
[]
end
params[:execution_environment]
.permit(:docker_image, :editor_mode, :file_extension, :file_type_id, :help, :indent_size, :memory_limit, :cpu_limit, :name,
:network_enabled, :privileged_execution, :permitted_execution_time, :pool_size, :run_command, :test_command, :testing_framework)
.merge(user: current_user, exposed_ports:)
end
end
private :execution_environment_params
def edit
# Add the current execution_environment if not already present in the list
@docker_images |= [@execution_environment.docker_image]
end
def create
@execution_environment = ExecutionEnvironment.new(execution_environment_params)
authorize!
create_and_respond(object: @execution_environment)
end
def set_docker_images
@docker_images ||= ExecutionEnvironment.pluck(:docker_image)
@docker_images += Runner.strategy_class.available_images
rescue Runner::Error => e
flash.now[:warning] = ERB::Util.html_escape e.message
ensure
@docker_images = @docker_images.sort.uniq
end
private :set_docker_images
def set_execution_environment
@execution_environment = ExecutionEnvironment.find(params[:id])
authorize!
end
private :set_execution_environment
def set_testing_framework_adapters
Rails.application.eager_load!
@testing_framework_adapters = TestingFrameworkAdapter.descendants.sort_by(&:framework_name).map do |klass|
[klass.framework_name, klass.name]
end
end
private :set_testing_framework_adapters
def shell; end
def update
update_and_respond(object: @execution_environment, params: execution_environment_params)
end
def destroy
destroy_and_respond(object: @execution_environment)
end
def sync_to_runner_management
return unless Runner.management_active?
begin
Runner.strategy_class.sync_environment(@execution_environment)
rescue Runner::Error => e
Rails.logger.warn { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" }
redirect_to @execution_environment, alert: t('execution_environments.index.synchronize.failure', error: ERB::Util.html_escape(e.message))
else
redirect_to @execution_environment, notice: t('execution_environments.index.synchronize.success')
end
end
def sync_all_to_runner_management
authorize ExecutionEnvironment
return unless Runner.management_active?
success = []
begin
# Get a list of all existing execution environments and mark them as a potential candidate for removal
environments_to_remove = Runner.strategy_class.environments.pluck(:id)
success << true
rescue Runner::Error => e
Rails.logger.debug { "Runner error while getting all execution environments: #{e.message}" }
Sentry.capture_exception(e)
environments_to_remove = []
success << false
end
success += ExecutionEnvironment.all.map do |execution_environment|
# Sync all current execution environments and prevent deletion of those just synced
environments_to_remove -= [execution_environment.id]
Runner.strategy_class.sync_environment(execution_environment)
rescue Runner::Error => e
Rails.logger.debug { "Runner error while synchronizing execution environment with id #{execution_environment.id}: #{e.message}" }
Sentry.capture_exception(e)
false
end
success += environments_to_remove.map do |execution_environment_id|
# Remove execution environments not synced. We temporarily use a record which is not persisted
execution_environment = ExecutionEnvironment.new(id: execution_environment_id)
Runner.strategy_class.remove_environment(execution_environment)
rescue Runner::Error => e
Rails.logger.debug { "Runner error while deleting execution environment with id #{execution_environment.id}: #{e.message}" }
Sentry.capture_exception(e)
false
end
if success.all?
redirect_to ExecutionEnvironment, notice: t('execution_environments.index.synchronize_all.success')
else
redirect_to ExecutionEnvironment, alert: t('execution_environments.index.synchronize_all.failure')
end
end
def augment_files_for_download(files)
files.map do |file|
# Downloadable files get an indicator whether we performed a privileged execution.
# The download path is added dynamically in the frontend.
file.privileged_execution = @privileged_execution
file
end
end
private :augment_files_for_download
end