openc3-cosmos-script-runner-api/app/models/script.rb
# encoding: ascii-8bit
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# Modified by OpenC3, Inc.
# All changes Copyright 2024, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
require 'tempfile'
require 'openc3/utilities/target_file'
require 'openc3/script/suite'
require 'openc3/script/suite_runner'
require 'openc3/tools/test_runner/test'
OpenC3.require_file 'openc3/utilities/store'
class Script < OpenC3::TargetFile
def self.all(scope)
super(scope, nil, include_temp: true) # No path matchers
end
def self.lock(scope, name, username)
name = name.split('*')[0] # Split '*' that indicates modified
OpenC3::Store.hset("#{scope}__script-locks", name, username)
end
def self.unlock(scope, name)
name = name.split('*')[0] # Split '*' that indicates modified
OpenC3::Store.hdel("#{scope}__script-locks", name)
end
def self.locked?(scope, name)
name = name.split('*')[0] # Split '*' that indicates modified
locked_by = OpenC3::Store.hget("#{scope}__script-locks", name)
locked_by ||= false
locked_by
end
def self.get_breakpoints(scope, name)
breakpoints = OpenC3::Store.hget("#{scope}__script-breakpoints", name.split('*')[0]) # Split '*' that indicates modified
return JSON.parse(breakpoints, :allow_nan => true, :create_additions => true) if breakpoints
[]
end
def self.process_suite(name, contents, new_process: true, username: nil, scope:)
python = false
python = true if File.extname(name) == '.py'
start = Time.now
if python
temp = Tempfile.new(%w[suite .py])
else
temp = Tempfile.new(%w[suite .rb])
end
# Remove any carriage returns which ruby doesn't like
temp.write(contents.gsub(/\r/, ' '))
temp.close
# We open a new process so as to not pollute the API with require
results = nil
success = true
if new_process or python
if python
runner_path = File.join(RAILS_ROOT, 'scripts', 'run_suite_analysis.py')
process = ChildProcess.build('python', runner_path.to_s, scope, temp.path)
else
runner_path = File.join(RAILS_ROOT, 'scripts', 'run_suite_analysis.rb')
process = ChildProcess.build('ruby', runner_path.to_s, scope, temp.path)
end
process.cwd = File.join(RAILS_ROOT, 'scripts')
# Check for offline access token
model = nil
model = OpenC3::OfflineAccessModel.get_model(name: username, scope: scope) if username and username != ''
# Set proper secrets for running script
process.environment['SECRET_KEY_BASE'] = nil
process.environment['OPENC3_REDIS_USERNAME'] = ENV['OPENC3_SR_REDIS_USERNAME']
process.environment['OPENC3_REDIS_PASSWORD'] = ENV['OPENC3_SR_REDIS_PASSWORD']
process.environment['OPENC3_BUCKET_USERNAME'] = ENV['OPENC3_SR_BUCKET_USERNAME']
process.environment['OPENC3_BUCKET_PASSWORD'] = ENV['OPENC3_SR_BUCKET_PASSWORD']
process.environment['OPENC3_SR_REDIS_USERNAME'] = nil
process.environment['OPENC3_SR_REDIS_PASSWORD'] = nil
process.environment['OPENC3_SR_BUCKET_USERNAME'] = nil
process.environment['OPENC3_SR_BUCKET_PASSWORD'] = nil
process.environment['OPENC3_API_CLIENT'] = ENV['OPENC3_API_CLIENT']
if model and model.offline_access_token
auth = OpenC3::OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
valid_token = auth.get_token_from_refresh_token(model.offline_access_token)
if valid_token
process.environment['OPENC3_API_TOKEN'] = model.offline_access_token
else
model.offline_access_token = nil
model.update
raise "offline_access token invalid for script"
end
else
process.environment['OPENC3_API_USER'] = ENV['OPENC3_API_USER']
if ENV['OPENC3_SERVICE_PASSWORD']
process.environment['OPENC3_API_PASSWORD'] = ENV['OPENC3_SERVICE_PASSWORD']
else
# The viewer user doesn't have an offline access token (because they can't run scripts)
# but they still want to be able to view suite files
# Since processing a suite file requires running it they won't get the Suite chrome
# so return nothing here and allow Script Runner to simply view the suite file
return '', '', false
end
end
process.environment['GEM_HOME'] = ENV['GEM_HOME']
process.environment['PYTHONUSERBASE'] = ENV['PYTHONUSERBASE']
# Spawned process should not be controlled by same Bundler constraints as spawning process
ENV.each do |key, _value|
if key =~ /^BUNDLE/
process.environment[key] = nil
end
end
process.environment['RUBYOPT'] = nil # Removes loading bundler setup
process.environment['OPENC3_SCOPE'] = scope
stdout = Tempfile.new("child-stdout")
stdout.sync = true
stderr = Tempfile.new("child-stderr")
stderr.sync = true
process.io.stdout = stdout
process.io.stderr = stderr
process.start
process.wait
stdout.rewind
stdout_results = stdout.read
stdout.close
stdout.unlink
stderr.rewind
stderr_results = stderr.read
stderr.close
stderr.unlink
success = process.exit_code == 0
else
require temp.path
stdout_results = OpenC3::SuiteRunner.build_suites.as_json(:allow_nan => true).to_json(:allow_nan => true)
end
temp.delete
puts "Processed #{name} in #{Time.now - start} seconds"
# Make sure we're getting the last line which should be the suite
puts "Stdout Results:#{stdout_results}:"
puts "Stderr Results:#{stderr_results}:"
stdout_results = stdout_results.split("\n")[-1] if stdout_results
return stdout_results, stderr_results, success
end
def self.create(params)
existing = body(params[:scope], params[:name])
# Commit if there is no existing or something has changed
if existing.nil? or existing != params[:text]
super(params[:scope], params[:name], params[:text])
end
breakpoints = params[:breakpoints]
if breakpoints
if breakpoints.empty?
OpenC3::Store.hdel("#{params[:scope]}__script-breakpoints", params[:name])
else
OpenC3::Store.hset("#{params[:scope]}__script-breakpoints", params[:name],
breakpoints.as_json(:allow_nan => true).to_json(:allow_nan => true))
end
end
end
def self.delete_temp(scope)
files = super(scope)
files.each do |name|
# Remove any breakpoints associated with the temp files
OpenC3::Store.hdel("#{scope}__script-breakpoints", "#{TEMP_FOLDER}/#{File.basename(name)}")
end
end
def self.destroy(scope, name)
super(scope, name)
OpenC3::Store.hdel("#{scope}__script-breakpoints", name)
end
def self.run(
scope,
name,
suite_runner = nil,
disconnect = false,
environment = nil,
user_full_name = nil,
username = nil
)
RunningScript.spawn(scope, name, suite_runner, disconnect, environment, user_full_name, username)
end
def self.instrumented(filename, text)
language = detect_language(text, filename)
if language == 'ruby'
return {
'title' => 'Instrumented Script',
'description' =>
RunningScript.instrument_script(
text,
filename,
true,
).split("\n").as_json(:allow_nan => true).to_json(:allow_nan => true),
}
else
start = Time.now
temp = Tempfile.new(%w[instrument .py])
temp.write(text)
temp.close
runner_path = File.join(RAILS_ROOT, 'scripts', 'run_instrument.py')
process = ChildProcess.build('python', runner_path.to_s, temp.path)
process.cwd = File.join(RAILS_ROOT, 'scripts')
stdout = Tempfile.new("child-stdout")
stdout.sync = true
stderr = Tempfile.new("child-stderr")
stderr.sync = true
process.io.stdout = stdout
process.io.stderr = stderr
process.start
process.wait
stdout.rewind
stdout_results = stdout.read
stdout.close
stdout.unlink
stderr.rewind
stderr_results = stderr.read
stderr.close
stderr.unlink
success = process.exit_code == 0
puts "Processed Instrumenting #{filename} in #{Time.now - start} seconds"
# Make sure we're getting the last line which should be the suite
puts "Stdout Results:#{stdout_results}:"
puts "Stderr Results:#{stderr_results}:"
# stdout_results = stdout_results.split("\n")[-1] if stdout_results
if success
return {
'title' => 'Instrumented Script',
'description' =>
stdout_results.to_s.split("\n").as_json(:allow_nan => true).to_json(:allow_nan => true),
}
else
return {
'title' => 'Error Instrumenting Script',
'description' =>
(stdout_results.to_s + stderr_results.to_s).split("\n").as_json(:allow_nan => true).to_json(:allow_nan => true),
}
end
end
end
def self.detect_language(text, filename = nil)
if filename
if File.extname(filename) == '.rb'
return 'ruby'
elsif File.extname(filename) == '.py'
return 'python'
end
end
return 'ruby' if text =~ /^\s*(require|load|puts) /
return 'python' if text =~ /^\s*(import|from) /
return 'ruby' if text =~ /^\s*end\s*$/
return 'python' if text =~ /^\s*(if|def|while|else|elif|class).*:\s*$/
return 'ruby' # otherwise guess Ruby
end
def self.syntax(filename, text)
language = detect_language(text, filename)
if language == 'ruby'
check_process = IO.popen('ruby -c -rubygems 2>&1', 'r+')
check_process.write("require 'openc3'; require 'openc3/script'; " + text)
check_process.close_write
results = check_process.readlines
check_process.close
if results
if results.any?(/Syntax OK/)
return(
{
'title' => 'Syntax Check Successful',
'description' => results.as_json(:allow_nan => true).to_json(:allow_nan => true),
}
)
else
# Results is an array of strings like this: ":2: syntax error ..."
# Normally the procedure comes before the first colon but since we
# are writing to the process this is blank so we throw it away
results.map! { |result| result.split(':')[1..-1].join(':') }
return(
{ 'title' => 'Syntax Check Failed', 'description' => results.as_json(:allow_nan => true).to_json(:allow_nan => true) }
)
end
else
return(
{
'title' => 'Syntax Check Exception',
'description' => 'Ruby syntax check unexpectedly returned nil',
}
)
end
else
# Python
tf = nil
begin
tf = Tempfile.new("test_script.py")
tf.write(text)
tf.close
results, _ = Open3.capture2e("python -m py_compile #{tf.path}")
lines = []
if results and results.length > 0
results.each_line do |line|
lines << line
end
return(
{ 'title' => 'Syntax Check Failed', 'description' => lines.as_json(:allow_nan => true).to_json(:allow_nan => true) }
)
else
return(
{
'title' => 'Syntax Check Successful',
'description' => ["Syntax OK"].as_json(:allow_nan => true).to_json(:allow_nan => true),
}
)
end
ensure
tf.unlink if tf
end
end
end
end