Files
codeocean/lib/runner/strategy/poseidon.rb
Sebastian Serth c59eaa05bb Handle bootstrapping of Poseidon when no environments
When starting Poseidon with an empty Nomad cluster, the list of existing environments is empty. In this case, however, our error handler threw an error, which was not correct.

Fixes CODEOCEAN-M7
2023-04-14 13:37:45 +02:00

372 lines
14 KiB
Ruby

# frozen_string_literal: true
class Runner::Strategy::Poseidon < Runner::Strategy
ERRORS = %w[NOMAD_UNREACHABLE NOMAD_OVERLOAD NOMAD_INTERNAL_SERVER_ERROR UNKNOWN].freeze
ERRORS.each do |error|
define_singleton_method :"error_#{error.downcase}" do
error
end
end
def initialize(runner_id, _environment)
super
@allocation_id = runner_id
end
def self.initialize_environment
# There is no additional initialization required for Poseidon
nil
end
def self.environments
url = "#{config[:url]}/execution-environments"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Getting list of execution environments at #{url}" }
response = http_connection.get url
case response.status
when 200
response_body = parse response
if response_body.key? :executionEnvironments
raise(Runner::Error::UnexpectedResponse.new("Could not get the list of execution environments in Poseidon, got response: #{response.as_json}"))
else
response_body[:executionEnvironments] || []
end
else
handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Could not get the list of execution environments because of Faraday error: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished getting the list of execution environments" }
end
def self.sync_environment(environment)
url = "#{config[:url]}/execution-environments/#{environment.id}"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Synchronizing execution environment at #{url}" }
response = http_connection.put url, environment.to_json
return true if [201, 204].include? response.status
raise Runner::Error::UnexpectedResponse.new("Could not synchronize execution environment in Poseidon, got response: #{response.as_json}")
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Could not synchronize execution environment because of Faraday error: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished synchronizing execution environment" }
end
def self.remove_environment(environment)
url = "#{config[:url]}/execution-environments/#{environment.id}"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Deleting execution environment at #{url}" }
response = http_connection.delete url
return true if response.status == 204
raise Runner::Error::UnexpectedResponse.new("Could not delete execution environment in Poseidon, got response: #{response.as_json}")
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Could not delete execution environment because of Faraday error: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished deleting execution environment" }
end
def self.request_from_management(environment)
url = "#{config[:url]}/runners"
inactivity_timeout = [config[:unused_runner_expiration_time], environment.permitted_execution_time].max
body = {
executionEnvironmentId: environment.id,
inactivityTimeout: inactivity_timeout.to_i.seconds,
}
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Requesting new runner at #{url}" }
response = http_connection.post url, body.to_json
case response.status
when 200
response_body = parse response
runner_id = response_body[:runnerId]
runner_id.presence || raise(Runner::Error::UnexpectedResponse.new('Poseidon did not send a runner id'))
when 404
raise Runner::Error::EnvironmentNotFound.new
else
handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished new runner request" }
end
def destroy_at_management
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Destroying runner at #{runner_url}" }
response = self.class.http_connection.delete runner_url
self.class.handle_error response unless response.status == 204
rescue Runner::Error::RunnerNotFound
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Runner not found, nothing to destroy" }
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished destroying runner" }
end
def copy_files(files)
url = "#{runner_url}/files"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Sending files to #{url}" }
# First create all the directories to apply the sticky bit for additional protection
# See https://github.com/openHPI/poseidon/pull/240
copy = files.uniq(&:path).filter_map do |file|
next if file.path.blank?
{path: "#{file.path}/"}
end
copy += files.map do |file|
{
path: file.filepath,
content: Base64.strict_encode64(file.read || ''),
}
end
# First, clean the workspace and second, copy all files to their location.
# This ensures that no artifacts from a previous submission remain in the workspace.
body = {copy:, delete: ['./*']}
response = self.class.http_connection.patch url, body.to_json
return if response.status == 204
Runner.destroy(@allocation_id) if response.status == 400
self.class.handle_error response
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished copying files" }
end
def retrieve_files(path: './', recursive: true, privileged_execution: false)
url = "#{runner_url}/files"
params = {
path:,
recursive:,
privilegedExecution: privileged_execution || @execution_environment.privileged_execution,
}
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Retrieving files at #{runner_url} with #{params}" }
response = self.class.http_connection.get url, params
case response.status
when 200
JSON.parse(response.body)
when 424
raise Runner::Error::WorkspaceError.new("The path #{path} is not available or could not be read.")
else
self.class.handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished listing files" }
end
def download_file(file, privileged_execution: false, &block)
url = "#{runner_url}/files/raw"
params = {
path: file,
privilegedExecution: privileged_execution || @execution_environment.privileged_execution,
}
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Download file #{params} from #{runner_url}" }
response = self.class.new_http_connection.get url, params do |request|
content_length = nil
content_type = nil
next if block.blank?
request.options.on_data = proc do |chunk, _overall_received_bytes, env|
next unless env.success?
content_length ||= env.response_headers['Content-Length'].presence&.to_i
content_type ||= env.response_headers['Content-Type'].presence || 'application/octet-stream'
yield chunk, content_length, content_type
end
request.options
end
case response.status
when 200
response.body
when 424
raise Runner::Error::WorkspaceError.new("The file #{file} is not available or could not be read.")
else
self.class.handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished downloading file" }
end
def attach_to_execution(command, event_loop, starting_time, privileged_execution: false)
websocket_url = execute_command(command, privileged_execution:)
socket = Connection.new(websocket_url, self, event_loop)
yield(socket, starting_time)
socket
end
def self.available_images
# Images are pulled when needed for a new execution environment
# and cleaned up automatically if no longer in use.
# Hence, there is no additional image that we need to return
[]
end
def self.config
@config ||= CodeOcean::Config.new(:code_ocean).read[:runner_management] || {}
end
def self.health
url = "#{config[:url]}/health"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Checking health from #{url}" }
response = http_connection.get url
case response.status
when 204
true
else
raise Runner::Error::UnexpectedResponse.new("Poseidon sent unexpected response status code #{response.status}")
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished getting health information" }
end
def self.release
url = "#{config[:url]}/version"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Getting release from #{url}" }
response = http_connection.get url
case response.status
when 200
JSON.parse(response.body)
when 404
'N/A'
else
handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
rescue JSON::ParserError => e
# Poseidon should not send invalid json
raise Runner::Error::UnexpectedResponse.new("Error parsing response from Poseidon: #{e.message}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished getting release information" }
end
def self.pool_size
url = "#{config[:url]}/statistics/execution-environments"
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Getting statistics from #{url}" }
response = http_connection.get url
case response.status
when 200
response_body = parse response
response_body
else
handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
rescue JSON::ParserError => e
# Poseidon should not send invalid json
raise Runner::Error::UnexpectedResponse.new("Error parsing response from Poseidon: #{e.message}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished getting statistics" }
end
def self.websocket_header
# The `tls` option is used to customize the validation of TLS connections.
# The `headers` option is used to pass the `Poseidon-Token` as part of the initial connection request.
{
tls: {root_cert_file: config[:ca_file]},
headers: {'Poseidon-Token' => config[:token]},
}
end
def self.handle_error(response)
case response.status
when 400
response_body = parse response
raise Runner::Error::BadRequest.new(response_body[:message])
when 401
raise Runner::Error::Unauthorized.new('Authentication with Poseidon failed')
when 410
raise Runner::Error::RunnerNotFound.new
when 500
response_body = parse response
error_code = response_body[:errorCode]
if error_code == error_nomad_overload
raise Runner::Error::NotAvailable.new("Poseidon has no runner available (#{error_code}): #{response_body[:message]}")
else
raise Runner::Error::InternalServerError.new("Poseidon sent #{response_body[:errorCode]}: #{response_body[:message]}")
end
else
raise Runner::Error::UnexpectedResponse.new("Poseidon sent unexpected response status code #{response.status}")
end
end
def self.headers
@headers ||= {'Content-Type' => 'application/json', 'Poseidon-Token' => config[:token]}
end
def self.http_connection
@http_connection ||= Faraday.new(ssl: {ca_file: config[:ca_file]}, headers:) do |faraday|
faraday.adapter :net_http_persistent
end
end
def self.new_http_connection
Faraday.new(ssl: {ca_file: config[:ca_file]}, headers:) do |faraday|
faraday.adapter :net_http
end
end
def self.parse(response)
JSON.parse(response.body).deep_symbolize_keys
rescue JSON::ParserError => e
# Poseidon should not send invalid json
raise Runner::Error::UnexpectedResponse.new("Error parsing response from Poseidon: #{e.message}")
end
private
def execute_command(command, privileged_execution: false)
url = "#{runner_url}/execute"
body = {
command:,
timeLimit: @execution_environment.permitted_execution_time,
privilegedExecution: privileged_execution || @execution_environment.privileged_execution,
}
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Preparing command execution at #{url}: #{command}" }
response = self.class.http_connection.post url, body.to_json
case response.status
when 200
response_body = self.class.parse response
websocket_url = response_body[:websocketUrl]
websocket_url.presence || raise(Runner::Error::UnexpectedResponse.new('Poseidon did not send a WebSocket URL'))
else
self.class.handle_error response
end
rescue Faraday::Error => e
raise Runner::Error::FaradayError.new("Request to Poseidon failed: #{e.inspect}")
ensure
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Finished command execution preparation" }
end
def runner_url
"#{self.class.config[:url]}/runners/#{@allocation_id}"
end
class Connection < Runner::Connection
def decode(event_data)
JSON.parse(event_data)
rescue JSON::ParserError => e
@error = Runner::Error::UnexpectedResponse.new("The WebSocket message from Poseidon could not be decoded to JSON: #{e.inspect}")
close(:error)
end
def encode(data)
"#{data}\n"
end
end
end