openc3 7.2.1 → 7.3.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.
- checksums.yaml +4 -4
- data/bin/openc3cli +97 -6
- data/bin/pipinstall +5 -4
- data/bin/pipuninstall +5 -1
- data/bin/uvinstall +149 -0
- data/data/config/item_modifiers.yaml +2 -1
- data/data/config/microservice.yaml +5 -1
- data/data/config/screen.yaml +2 -1
- data/data/config/target.yaml +21 -0
- data/data/config/tool.yaml +3 -1
- data/data/config/widgets.yaml +16 -3
- data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
- data/data/xtce_schemas/xml.xsd +116 -0
- data/ext/openc3/ext/telemetry/telemetry.c +1 -1
- data/lib/openc3/accessors/template_accessor.rb +2 -2
- data/lib/openc3/api/interface_api.rb +2 -2
- data/lib/openc3/api/limits_api.rb +27 -13
- data/lib/openc3/api/router_api.rb +3 -3
- data/lib/openc3/api/tlm_api.rb +1 -1
- data/lib/openc3/interfaces/http_client_interface.rb +1 -1
- data/lib/openc3/io/json_drb.rb +24 -8
- data/lib/openc3/io/json_rpc.rb +0 -2
- data/lib/openc3/logs/log_writer.rb +43 -7
- data/lib/openc3/logs/packet_log_writer.rb +1 -2
- data/lib/openc3/microservices/interface_microservice.rb +1 -1
- data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
- data/lib/openc3/models/cvt_model.rb +5 -5
- data/lib/openc3/models/gem_model.rb +1 -1
- data/lib/openc3/models/interface_model.rb +1 -1
- data/lib/openc3/models/microservice_model.rb +21 -0
- data/lib/openc3/models/plugin_model.rb +204 -50
- data/lib/openc3/models/python_package_model.rb +207 -11
- data/lib/openc3/models/scope_model.rb +34 -30
- data/lib/openc3/models/target_model.rb +180 -6
- data/lib/openc3/models/tool_model.rb +33 -20
- data/lib/openc3/operators/microservice_operator.rb +33 -4
- data/lib/openc3/packets/commands.rb +4 -4
- data/lib/openc3/packets/limits.rb +15 -7
- data/lib/openc3/packets/packet.rb +1 -1
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
- data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
- data/lib/openc3/packets/structure.rb +2 -2
- data/lib/openc3/packets/telemetry.rb +3 -3
- data/lib/openc3/script/commands.rb +1 -1
- data/lib/openc3/script/extract.rb +20 -25
- data/lib/openc3/script/web_socket_api.rb +44 -4
- data/lib/openc3/topics/command_decom_topic.rb +16 -1
- data/lib/openc3/topics/limits_event_topic.rb +1 -1
- data/lib/openc3/utilities/aws_bucket.rb +10 -2
- data/lib/openc3/utilities/csv.rb +5 -5
- data/lib/openc3/utilities/local_mode.rb +4 -1
- data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
- data/lib/openc3/utilities/running_script.rb +145 -90
- data/lib/openc3/utilities/script.rb +46 -10
- data/lib/openc3/version.rb +6 -6
- data/templates/plugin/plugin.gemspec +1 -1
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_svelte/package.json +1 -1
- data/templates/tool_vue/package.json +3 -3
- data/templates/widget/package.json +2 -2
- metadata +5 -3
- data/templates/tool_vue/.nycrc +0 -3
- data/templates/widget/.nycrc +0 -3
|
@@ -59,7 +59,7 @@ module OpenC3
|
|
|
59
59
|
def packets(target_name)
|
|
60
60
|
upcase_target_name = target_name.to_s.upcase
|
|
61
61
|
target_packets = @config.telemetry[upcase_target_name]
|
|
62
|
-
raise "Telemetry target '#{upcase_target_name}' does not exist" unless target_packets
|
|
62
|
+
raise "Telemetry target '#{upcase_target_name}' does not exist (packets lookup)" unless target_packets
|
|
63
63
|
|
|
64
64
|
target_packets
|
|
65
65
|
end
|
|
@@ -177,7 +177,7 @@ module OpenC3
|
|
|
177
177
|
if LATEST_PACKET_NAME.casecmp(packet_name).zero?
|
|
178
178
|
target_upcase = target_name.to_s.upcase
|
|
179
179
|
target_latest_data = @config.latest_data[target_upcase]
|
|
180
|
-
raise "Telemetry
|
|
180
|
+
raise "Telemetry target '#{target_upcase}' does not exist (item_names lookup)" unless target_latest_data
|
|
181
181
|
|
|
182
182
|
item_names = target_latest_data.keys
|
|
183
183
|
else
|
|
@@ -208,7 +208,7 @@ module OpenC3
|
|
|
208
208
|
target_upcase = target_name.to_s.upcase
|
|
209
209
|
item_upcase = item_name.to_s.upcase
|
|
210
210
|
target_latest_data = @config.latest_data[target_upcase]
|
|
211
|
-
raise "Telemetry target '#{target_upcase}' does not exist" unless target_latest_data
|
|
211
|
+
raise "Telemetry target '#{target_upcase}' does not exist (latest_packets lookup)" unless target_latest_data
|
|
212
212
|
|
|
213
213
|
packets = @config.latest_data[target_upcase][item_upcase]
|
|
214
214
|
raise "Telemetry item '#{target_upcase} #{LATEST_PACKET_NAME} #{item_upcase}' does not exist" unless packets
|
|
@@ -106,7 +106,7 @@ module OpenC3
|
|
|
106
106
|
cmd_params.each do |param_name, _param_value|
|
|
107
107
|
param = command['items'].find { |item| item['name'] == param_name }
|
|
108
108
|
unless param
|
|
109
|
-
raise "
|
|
109
|
+
raise "Item '#{target_name} #{cmd_name} #{param_name}' does not exist (command parameter validation)"
|
|
110
110
|
end
|
|
111
111
|
end
|
|
112
112
|
_log_cmd(command, raw, no_range, no_hazardous)
|
|
@@ -20,7 +20,11 @@ require 'openc3/utilities/store'
|
|
|
20
20
|
|
|
21
21
|
module OpenC3
|
|
22
22
|
module Extract
|
|
23
|
-
|
|
23
|
+
# Tokenizer for command parameters: matches double quoted strings, single quoted strings,
|
|
24
|
+
# bracket delimited arrays (one level of nesting), a bare comma delimiter, or bare words
|
|
25
|
+
# (runs of non-whitespace, non-comma characters). Commas are tokenized separately so
|
|
26
|
+
# whitespace around them is optional.
|
|
27
|
+
SCANNING_REGULAR_EXPRESSION = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.|\[(?:[^\\\[\]]|\\.)*\])*\]) | , | [^\s,]+ }x # "
|
|
24
28
|
|
|
25
29
|
private
|
|
26
30
|
|
|
@@ -88,35 +92,26 @@ module OpenC3
|
|
|
88
92
|
second_half = split_string[1].scan(SCANNING_REGULAR_EXPRESSION)
|
|
89
93
|
keyword = nil
|
|
90
94
|
value = nil
|
|
91
|
-
comma = nil
|
|
92
95
|
second_half.each do |item|
|
|
93
|
-
|
|
96
|
+
if item == ','
|
|
97
|
+
# A comma completes the current keyword / value pair.
|
|
98
|
+
# A comma with nothing pending is a leading or duplicated comma.
|
|
99
|
+
raise "Missing command parameter before comma: #{text}" unless keyword
|
|
100
|
+
raise "Missing value for last command parameter: #{text}" unless value
|
|
101
|
+
add_cmd_parameter(keyword, value, packet, cmd_params)
|
|
102
|
+
keyword = nil
|
|
103
|
+
value = nil
|
|
104
|
+
elsif keyword.nil?
|
|
94
105
|
keyword = item
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
value = item[0..-2]
|
|
100
|
-
comma = true
|
|
101
|
-
else
|
|
102
|
-
value = item
|
|
103
|
-
next
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
unless comma
|
|
107
|
-
raise "Missing comma in command parameters: #{text}" if item != ','
|
|
106
|
+
elsif value.nil?
|
|
107
|
+
value = item
|
|
108
|
+
else
|
|
109
|
+
raise "Missing comma in command parameters: #{text}"
|
|
108
110
|
end
|
|
109
|
-
add_cmd_parameter(keyword, value, packet, cmd_params)
|
|
110
|
-
keyword = nil
|
|
111
|
-
value = nil
|
|
112
|
-
comma = nil
|
|
113
111
|
end
|
|
114
112
|
if keyword
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
else
|
|
118
|
-
raise "Missing value for last command parameter: #{text}"
|
|
119
|
-
end
|
|
113
|
+
raise "Missing value for last command parameter: #{text}" unless value
|
|
114
|
+
add_cmd_parameter(keyword, value, packet, cmd_params)
|
|
120
115
|
end
|
|
121
116
|
end
|
|
122
117
|
|
|
@@ -22,7 +22,10 @@ module OpenC3
|
|
|
22
22
|
|
|
23
23
|
# Create the WebsocketApi object. If a block is given will automatically connect/disconnect
|
|
24
24
|
def initialize(url:, write_timeout: 10.0, read_timeout: 10.0, connect_timeout: 5.0, authentication: nil, scope: $openc3_scope, &block)
|
|
25
|
-
|
|
25
|
+
# $openc3_scope is only set inside the Script Runner / microservice
|
|
26
|
+
# environment. Fall back to OPENC3_SCOPE (mirrors the Python client) so a
|
|
27
|
+
# bare `ruby script.rb` doesn't send a nil scope that the server rejects.
|
|
28
|
+
@scope = scope || ENV.fetch('OPENC3_SCOPE', 'DEFAULT')
|
|
26
29
|
@authentication = authentication.nil? ? generate_auth() : authentication
|
|
27
30
|
@url = url
|
|
28
31
|
@write_timeout = write_timeout
|
|
@@ -100,6 +103,34 @@ module OpenC3
|
|
|
100
103
|
json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true)
|
|
101
104
|
@stream.write(JSON.generate(json_hash, allow_nan: true))
|
|
102
105
|
@subscribed = true
|
|
106
|
+
wait_for_subscribed()
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Block until the server confirms the subscription. ActionCable / anycable-go
|
|
111
|
+
# process 'subscribe' and 'message' commands as independent RPCs, so an action
|
|
112
|
+
# (add/remove) written immediately after subscribe can reach
|
|
113
|
+
# StreamingChannel#add before the subscription's broadcaster exists, where it
|
|
114
|
+
# is silently dropped (a no-op) and no data ever streams. Waiting for
|
|
115
|
+
# confirm_subscription guarantees the broadcaster is ready before any action
|
|
116
|
+
# is written.
|
|
117
|
+
def wait_for_subscribed
|
|
118
|
+
while true
|
|
119
|
+
message = @stream.read
|
|
120
|
+
raise "WebSocket closed before subscription was confirmed" if message.nil? || message.empty?
|
|
121
|
+
|
|
122
|
+
json_hash = JSON.parse(message, allow_nan: true, create_additions: true)
|
|
123
|
+
case json_hash['type']
|
|
124
|
+
when 'confirm_subscription'
|
|
125
|
+
return
|
|
126
|
+
when 'reject_subscription'
|
|
127
|
+
raise "Subscription Rejected"
|
|
128
|
+
when 'disconnect'
|
|
129
|
+
raise "Unauthorized" if json_hash['reason'] == 'unauthorized'
|
|
130
|
+
else
|
|
131
|
+
# Ignore welcome / ping and keep waiting for confirmation
|
|
132
|
+
next
|
|
133
|
+
end
|
|
103
134
|
end
|
|
104
135
|
end
|
|
105
136
|
|
|
@@ -116,6 +147,13 @@ module OpenC3
|
|
|
116
147
|
|
|
117
148
|
# Send an ActionCable command
|
|
118
149
|
def write_action(data_hash)
|
|
150
|
+
# Subscribe first so the token is present in @identifier before we
|
|
151
|
+
# serialize it below. ActionCable matches a 'message' command to its
|
|
152
|
+
# subscription by the exact identifier string; if subscribe() injected the
|
|
153
|
+
# token only afterward, the message identifier (no token) would not match
|
|
154
|
+
# the subscription identifier (with token) and the server would silently
|
|
155
|
+
# ignore the action.
|
|
156
|
+
subscribe()
|
|
119
157
|
json_hash = {}
|
|
120
158
|
json_hash['command'] = 'message'
|
|
121
159
|
json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true)
|
|
@@ -354,7 +392,8 @@ module OpenC3
|
|
|
354
392
|
# PACKET - Packet name
|
|
355
393
|
# VALUETYPE - RAW, CONVERTED, FORMATTED, or PURE (pure means all types as stored in log)
|
|
356
394
|
#
|
|
357
|
-
def add(items: nil, packets: nil, start_time: nil, end_time: nil, scope:
|
|
395
|
+
def add(items: nil, packets: nil, start_time: nil, end_time: nil, scope: nil)
|
|
396
|
+
scope ||= @scope
|
|
358
397
|
data_hash = {}
|
|
359
398
|
data_hash['action'] = 'add'
|
|
360
399
|
if start_time
|
|
@@ -395,7 +434,8 @@ module OpenC3
|
|
|
395
434
|
# PACKET - Packet name
|
|
396
435
|
# VALUETYPE - RAW, CONVERTED, FORMATTED, or PURE (pure means all types as stored in log)
|
|
397
436
|
#
|
|
398
|
-
def remove(items: nil, packets: nil, scope:
|
|
437
|
+
def remove(items: nil, packets: nil, scope: nil)
|
|
438
|
+
scope ||= @scope
|
|
399
439
|
data_hash = {}
|
|
400
440
|
data_hash['action'] = 'remove'
|
|
401
441
|
data_hash['items'] = items if items
|
|
@@ -407,7 +447,7 @@ module OpenC3
|
|
|
407
447
|
|
|
408
448
|
# Convenience method to read all data until end marker is received.
|
|
409
449
|
# Warning: DATA IS STORED IN RAM. Do not use this with large queries
|
|
410
|
-
def self.read_all(items: nil, packets: nil, start_time: nil, end_time:, scope:
|
|
450
|
+
def self.read_all(items: nil, packets: nil, start_time: nil, end_time:, scope: nil, timeout: nil)
|
|
411
451
|
read_all_start_time = Time.now
|
|
412
452
|
data = []
|
|
413
453
|
self.new do |api|
|
|
@@ -30,13 +30,28 @@ module OpenC3
|
|
|
30
30
|
received_count: packet.received_count }
|
|
31
31
|
# Read all RAW values at once - optimized by accessor
|
|
32
32
|
json_hash = packet.read_items(packet.sorted_items)
|
|
33
|
+
# Items with a write conversion have already been transformed by the time the
|
|
34
|
+
# value lands in the buffer, so reading the buffer back does not recover what
|
|
35
|
+
# the user actually commanded. Use the given values (as passed to build_cmd)
|
|
36
|
+
# for those items. State items are the exception: the user can give either the
|
|
37
|
+
# state name or the state value, so they always read from the buffer to log the
|
|
38
|
+
# normalized state name. Raw commands bypass the write conversions entirely so
|
|
39
|
+
# their given values are raw and must not be logged as converted.
|
|
40
|
+
given_values = packet.raw ? nil : packet.given_values
|
|
41
|
+
given_values = given_values.transform_keys { |key| key.to_s.upcase } if given_values
|
|
33
42
|
# Read additional value types using given_raw to avoid re-reading from buffer
|
|
34
43
|
packet.sorted_items.each do |item|
|
|
35
44
|
if item.hidden
|
|
36
45
|
json_hash.delete(item.name)
|
|
37
46
|
else
|
|
38
47
|
given_raw = json_hash[item.name]
|
|
39
|
-
|
|
48
|
+
if item.write_conversion or item.states
|
|
49
|
+
if item.write_conversion and !item.states and given_values and given_values.key?(item.name)
|
|
50
|
+
json_hash[item.name + "__C"] = given_values[item.name]
|
|
51
|
+
else
|
|
52
|
+
json_hash[item.name + "__C"] = packet.read_item(item, :CONVERTED, packet.buffer, given_raw)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
40
55
|
json_hash[item.name + "__F"] = packet.read_item(item, :FORMATTED, packet.buffer, given_raw) if item.format_string
|
|
41
56
|
end
|
|
42
57
|
end
|
|
@@ -100,7 +100,7 @@ module OpenC3
|
|
|
100
100
|
|
|
101
101
|
when :LIMITS_SET
|
|
102
102
|
sets = sets(scope: scope)
|
|
103
|
-
raise "
|
|
103
|
+
raise "Limits set '#{event[:set]}' does not exist" unless sets.key?(event[:set])
|
|
104
104
|
|
|
105
105
|
# Set all existing sets to "false"
|
|
106
106
|
sets = sets.transform_values! { |_key, _value| "false" }
|
|
@@ -213,6 +213,14 @@ module OpenC3
|
|
|
213
213
|
# If the key is not found return nil
|
|
214
214
|
rescue Aws::S3::Errors::NoSuchKey
|
|
215
215
|
nil
|
|
216
|
+
# AWS S3 returns 403 AccessDenied rather than 404 NoSuchKey when the caller
|
|
217
|
+
# does not have s3:ListBucket on the bucket. Callers which probe for an
|
|
218
|
+
# optional key (i.e. TargetFile.body checking targets_modified first) would
|
|
219
|
+
# otherwise raise instead of falling through, so treat it as not found.
|
|
220
|
+
# Warn since a genuine permission problem looks identical from here.
|
|
221
|
+
rescue Aws::S3::Errors::AccessDenied
|
|
222
|
+
Logger.warn("Access denied reading #{bucket}/#{key}. Treating as not found. Verify bucket permissions if this key should exist.")
|
|
223
|
+
nil
|
|
216
224
|
end
|
|
217
225
|
|
|
218
226
|
def list_objects(bucket:, prefix: nil, max_request: 1000, max_total: 100_000)
|
|
@@ -233,7 +241,7 @@ module OpenC3
|
|
|
233
241
|
# Array of objects with key and size methods
|
|
234
242
|
result
|
|
235
243
|
rescue Aws::S3::Errors::NoSuchBucket
|
|
236
|
-
raise NotFound, "Bucket '#{bucket}' does not exist
|
|
244
|
+
raise NotFound, "Bucket '#{bucket}' does not exist (list_objects)"
|
|
237
245
|
end
|
|
238
246
|
|
|
239
247
|
# Lists the files under a specified path
|
|
@@ -283,7 +291,7 @@ module OpenC3
|
|
|
283
291
|
end
|
|
284
292
|
result
|
|
285
293
|
rescue Aws::S3::Errors::NoSuchBucket
|
|
286
|
-
raise NotFound, "Bucket '#{bucket}' does not exist
|
|
294
|
+
raise NotFound, "Bucket '#{bucket}' does not exist (list_files)"
|
|
287
295
|
end
|
|
288
296
|
|
|
289
297
|
# get metadata for a specific object
|
data/lib/openc3/utilities/csv.rb
CHANGED
|
@@ -59,7 +59,7 @@ module OpenC3
|
|
|
59
59
|
# @param index [Integer] Which value to return
|
|
60
60
|
# @return [Boolean] Single value converted to a boolean (true or false)
|
|
61
61
|
def bool(item, index = 0)
|
|
62
|
-
raise "#{item} not found" unless keys.include?(item)
|
|
62
|
+
raise "CSV key '#{item}' not found for bool conversion" unless keys.include?(item)
|
|
63
63
|
|
|
64
64
|
if Range === index
|
|
65
65
|
@hash[item][index].map do |x|
|
|
@@ -91,7 +91,7 @@ module OpenC3
|
|
|
91
91
|
# @param index [Integer] Which value to return
|
|
92
92
|
# @return [Integer] Single value converted to an integer
|
|
93
93
|
def int(item, index = 0)
|
|
94
|
-
raise "#{item} not found" unless keys.include?(item)
|
|
94
|
+
raise "CSV key '#{item}' not found for int conversion" unless keys.include?(item)
|
|
95
95
|
|
|
96
96
|
if Range === index
|
|
97
97
|
@hash[item][index].map { |x| x.to_i }
|
|
@@ -107,7 +107,7 @@ module OpenC3
|
|
|
107
107
|
# @param index [Integer] Which value to return
|
|
108
108
|
# @return [Float] Single value converted to a float
|
|
109
109
|
def float(item, index = 0)
|
|
110
|
-
raise "#{item} not found" unless keys.include?(item)
|
|
110
|
+
raise "CSV key '#{item}' not found for float conversion" unless keys.include?(item)
|
|
111
111
|
|
|
112
112
|
if Range === index
|
|
113
113
|
@hash[item][index].map { |x| x.to_f }
|
|
@@ -122,7 +122,7 @@ module OpenC3
|
|
|
122
122
|
# @param index [Integer] Which value to return
|
|
123
123
|
# @return [String] Single value converted to a string
|
|
124
124
|
def string(item, index = 0)
|
|
125
|
-
raise "#{item} not found" unless keys.include?(item)
|
|
125
|
+
raise "CSV key '#{item}' not found for string lookup" unless keys.include?(item)
|
|
126
126
|
|
|
127
127
|
if Range === index
|
|
128
128
|
@hash[item][index].map { |x| x.to_s }
|
|
@@ -138,7 +138,7 @@ module OpenC3
|
|
|
138
138
|
# @param index [Integer] Which value to return
|
|
139
139
|
# @return [Symbol] Single value converted to a symbol
|
|
140
140
|
def symbol(item, index = 0)
|
|
141
|
-
raise "#{item} not found" unless keys.include?(item)
|
|
141
|
+
raise "CSV key '#{item}' not found for symbol lookup" unless keys.include?(item)
|
|
142
142
|
|
|
143
143
|
if Range === index
|
|
144
144
|
@hash[item][index].map { |x| x.intern }
|
|
@@ -485,7 +485,10 @@ module OpenC3
|
|
|
485
485
|
config_path = "#{OPENC3_LOCAL_MODE_PATH}/#{scope}/settings/#{name}.json"
|
|
486
486
|
return unless File.expand_path(config_path).start_with?(OPENC3_LOCAL_MODE_PATH)
|
|
487
487
|
FileUtils.mkdir_p(File.dirname(config_path))
|
|
488
|
-
# Anything can be stored as a setting
|
|
488
|
+
# Anything can be stored as a setting. Strings are written directly since
|
|
489
|
+
# they're already the serialized form. Anything else is written as JSON so
|
|
490
|
+
# sync_settings can read it back rather than a Ruby inspect string.
|
|
491
|
+
data = JSON.generate(data.as_json(:allow_nan => true)) unless String === data
|
|
489
492
|
File.write(config_path, data)
|
|
490
493
|
end
|
|
491
494
|
|
|
@@ -94,7 +94,11 @@ And so is this
|
|
|
94
94
|
# of the file and process the last line
|
|
95
95
|
if next_token.type == :EOF
|
|
96
96
|
if !line.empty?
|
|
97
|
-
|
|
97
|
+
# orig_line_no is normally set below (after this EOF check) but a final
|
|
98
|
+
# line that never reaches that assignment (e.g. a trailing bareword call
|
|
99
|
+
# with no parentheses and no newline at end of file) would yield nil.
|
|
100
|
+
# Fall back to the current token's start line so the line number is valid.
|
|
101
|
+
yield line, instrumentable, inside_begin, orig_line_no || token.location.start_line
|
|
98
102
|
end
|
|
99
103
|
break
|
|
100
104
|
end
|
|
@@ -432,7 +432,7 @@ class RunningScript
|
|
|
432
432
|
self.class.message_log
|
|
433
433
|
end
|
|
434
434
|
|
|
435
|
-
def self.spawn(scope, name, suite_runner = nil, disconnect = false, environment = nil, user_full_name = nil, username = nil, line_no = nil, end_line_no = nil)
|
|
435
|
+
def self.spawn(scope, name, suite_runner = nil, disconnect = false, environment = nil, user_full_name = nil, username = nil, line_no = nil, end_line_no = nil, python_venv = nil)
|
|
436
436
|
extension = File.extname(name).to_s.downcase
|
|
437
437
|
script_engine = nil
|
|
438
438
|
if extension == '.py'
|
|
@@ -468,103 +468,158 @@ class RunningScript
|
|
|
468
468
|
user_full_name ||= 'Anonymous'
|
|
469
469
|
start_time = Time.now.utc.iso8601
|
|
470
470
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
status_environment[env['key']] = env['value']
|
|
487
|
-
end
|
|
488
|
-
# Load the script specific ENV vars set by the GUI
|
|
489
|
-
# These can override the previously defined global env vars
|
|
490
|
-
if environment
|
|
491
|
-
environment.each do |env|
|
|
471
|
+
begin
|
|
472
|
+
process = ChildProcess.build(process_name, runner_path.to_s, running_script_id.to_s, scope)
|
|
473
|
+
process.io.inherit! # Helps with debugging
|
|
474
|
+
script_cwd = File.join(RAILS_ROOT, 'tmp', "script_#{running_script_id}")
|
|
475
|
+
FileUtils.mkdir_p(script_cwd)
|
|
476
|
+
process.cwd = script_cwd
|
|
477
|
+
|
|
478
|
+
# Check for offline access token
|
|
479
|
+
model = nil
|
|
480
|
+
model = OpenC3::OfflineAccessModel.get_model(name: username, scope: scope) if username != 'Anonymous'
|
|
481
|
+
|
|
482
|
+
# Load the global environment variables
|
|
483
|
+
status_environment = {}
|
|
484
|
+
values = OpenC3::EnvironmentModel.all(scope: scope).values
|
|
485
|
+
values.each do |env|
|
|
492
486
|
process.environment[env['key']] = env['value']
|
|
493
487
|
status_environment[env['key']] = env['value']
|
|
494
488
|
end
|
|
495
|
-
|
|
489
|
+
# Load the script specific ENV vars set by the GUI
|
|
490
|
+
# These can override the previously defined global env vars
|
|
491
|
+
if environment
|
|
492
|
+
environment.each do |env|
|
|
493
|
+
process.environment[env['key']] = env['value']
|
|
494
|
+
status_environment[env['key']] = env['value']
|
|
495
|
+
end
|
|
496
|
+
end
|
|
496
497
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
498
|
+
script_status = OpenC3::ScriptStatusModel.new(
|
|
499
|
+
name: running_script_id.to_s, # Unique id for this script
|
|
500
|
+
state: 'spawning', # State will be spawning until the script is running
|
|
501
|
+
shard: 0, # Future enhancement of script runner shards
|
|
502
|
+
filename: name, # Initial filename never changes
|
|
503
|
+
current_filename: name, # Current filename updates while we are running
|
|
504
|
+
line_no: 0, # 0 means not running yet
|
|
505
|
+
start_line_no: line_no || 1, # Line number to start running the script
|
|
506
|
+
end_line_no: end_line_no || nil, # Line number to stop running the script
|
|
507
|
+
username: username, # username of the person who started the script
|
|
508
|
+
user_full_name: user_full_name, # full name of the person who started the script
|
|
509
|
+
start_time: start_time, # Time the script started ISO format
|
|
510
|
+
end_time: nil, # Time the script ended ISO format
|
|
511
|
+
disconnect: disconnect, # Disconnect is set to true if the script is running in a disconnected mode
|
|
512
|
+
environment: status_environment, # hash of environment variables set for the script
|
|
513
|
+
suite_runner: suite_runner ? suite_runner : nil, # current suite information if any
|
|
514
|
+
errors: nil, # array of errors that occurred during the script run
|
|
515
|
+
pid: nil, # pid of the script process - set by the script itself when it starts
|
|
516
|
+
script_engine: script_engine, # script engine filename
|
|
517
|
+
updated_at: nil, # Set by create/update
|
|
518
|
+
scope: scope # Scope of the script
|
|
519
|
+
)
|
|
520
|
+
script_status.create()
|
|
521
|
+
|
|
522
|
+
# Set proper secrets for running script
|
|
523
|
+
process.environment['SECRET_KEY_BASE'] = nil
|
|
524
|
+
process.environment['OPENC3_REDIS_USERNAME'] = ENV['OPENC3_SR_REDIS_USERNAME']
|
|
525
|
+
process.environment['OPENC3_REDIS_PASSWORD'] = ENV['OPENC3_SR_REDIS_PASSWORD']
|
|
526
|
+
process.environment['OPENC3_BUCKET_USERNAME'] = ENV['OPENC3_SR_BUCKET_USERNAME']
|
|
527
|
+
process.environment['OPENC3_BUCKET_PASSWORD'] = ENV['OPENC3_SR_BUCKET_PASSWORD']
|
|
528
|
+
process.environment['OPENC3_SR_REDIS_USERNAME'] = nil
|
|
529
|
+
process.environment['OPENC3_SR_REDIS_PASSWORD'] = nil
|
|
530
|
+
process.environment['OPENC3_SR_BUCKET_USERNAME'] = nil
|
|
531
|
+
process.environment['OPENC3_SR_BUCKET_PASSWORD'] = nil
|
|
532
|
+
process.environment['OPENC3_API_CLIENT'] = ENV['OPENC3_API_CLIENT']
|
|
533
|
+
if model and model.offline_access_token
|
|
534
|
+
auth = OpenC3::OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
|
|
535
|
+
valid_token = auth.get_token_from_refresh_token(model.offline_access_token)
|
|
536
|
+
if valid_token
|
|
537
|
+
process.environment['OPENC3_API_TOKEN'] = model.offline_access_token
|
|
538
|
+
else
|
|
539
|
+
model.offline_access_token = nil
|
|
540
|
+
model.update
|
|
541
|
+
raise "offline_access token invalid for script"
|
|
542
|
+
end
|
|
537
543
|
else
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
544
|
+
process.environment['OPENC3_API_USER'] = ENV['OPENC3_API_USER']
|
|
545
|
+
if ENV['OPENC3_SERVICE_PASSWORD']
|
|
546
|
+
process.environment['OPENC3_API_PASSWORD'] = ENV['OPENC3_SERVICE_PASSWORD']
|
|
547
|
+
else
|
|
548
|
+
raise "No authentication available for script"
|
|
549
|
+
end
|
|
541
550
|
end
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
551
|
+
process.environment['GEM_HOME'] = ENV['GEM_HOME']
|
|
552
|
+
|
|
553
|
+
# Resolve the per-plugin Python venv for this script. For saved scripts
|
|
554
|
+
# we look up which plugin owns the target and check whether that plugin
|
|
555
|
+
# has an isolated UV venv. For temp scripts the frontend can pass a
|
|
556
|
+
# python_venv name directly, skipping the target lookup entirely.
|
|
557
|
+
python_venv_dir = nil
|
|
558
|
+
begin
|
|
559
|
+
target_name = name.split('/')[0].to_s.upcase
|
|
560
|
+
if target_name == '__TEMP__' && python_venv
|
|
561
|
+
# File.basename prevents path traversal (strips directory components)
|
|
562
|
+
safe_name = File.basename(python_venv.to_s)
|
|
563
|
+
candidate = "/gems/plugin_venvs/#{safe_name}/.venv"
|
|
564
|
+
python_venv_dir = candidate if File.directory?(candidate)
|
|
565
|
+
else
|
|
566
|
+
target_info = OpenC3::TargetModel.get(name: target_name, scope: scope)
|
|
567
|
+
if target_info && target_info['plugin']
|
|
568
|
+
sanitized_name = "#{scope}__#{target_info['plugin']}".tr('^a-zA-Z0-9_-', '_')
|
|
569
|
+
candidate = "/gems/plugin_venvs/#{sanitized_name}/.venv"
|
|
570
|
+
python_venv_dir = candidate if File.directory?(candidate)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
rescue => e
|
|
574
|
+
OpenC3::Logger.debug("Could not resolve plugin venv for script '#{name}': #{e.message}")
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
if python_venv_dir
|
|
578
|
+
process.environment['VIRTUAL_ENV'] = python_venv_dir
|
|
579
|
+
process.environment['PATH'] = "#{python_venv_dir}/bin:#{ENV.fetch('PATH', '')}"
|
|
580
|
+
process.environment['PYTHONUSERBASE'] = python_venv_dir
|
|
581
|
+
# Add plugin venv site-packages to PYTHONPATH so the base venv's Python
|
|
582
|
+
# binary can find plugin-specific packages. PYTHONPATH is always respected
|
|
583
|
+
# by CPython regardless of venv activation state.
|
|
584
|
+
site_packages = Dir.glob("#{python_venv_dir}/lib/python*/site-packages").first
|
|
585
|
+
existing_pythonpath = ENV.fetch('PYTHONPATH', '')
|
|
586
|
+
if site_packages
|
|
587
|
+
process.environment['PYTHONPATH'] = existing_pythonpath.empty? ? site_packages : "#{site_packages}:#{existing_pythonpath}"
|
|
588
|
+
else
|
|
589
|
+
process.environment['PYTHONPATH'] = existing_pythonpath.empty? ? nil : existing_pythonpath
|
|
590
|
+
end
|
|
546
591
|
else
|
|
547
|
-
|
|
592
|
+
system_venv = File.dirname(ENV['OPENC3_PYTHON_BIN'] || '/openc3/python/.venv/bin/python').chomp('/bin')
|
|
593
|
+
process.environment['VIRTUAL_ENV'] = system_venv
|
|
594
|
+
process.environment['PYTHONUSERBASE'] = ENV['PYTHONUSERBASE']
|
|
595
|
+
process.environment['PYTHONPATH'] = ENV['PYTHONPATH']
|
|
548
596
|
end
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
# Spawned process should not be controlled by same Bundler constraints as spawning process
|
|
556
|
-
ENV.each do |key, _value|
|
|
557
|
-
if key =~ /^BUNDLE/
|
|
558
|
-
process.environment[key] = nil
|
|
597
|
+
|
|
598
|
+
# Spawned process should not be controlled by same Bundler constraints as spawning process
|
|
599
|
+
ENV.each do |key, _value|
|
|
600
|
+
if key =~ /^BUNDLE/
|
|
601
|
+
process.environment[key] = nil
|
|
602
|
+
end
|
|
559
603
|
end
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
process.environment['RAILS_ROOT'] = RAILS_ROOT
|
|
604
|
+
process.environment['RUBYOPT'] = nil # Removes loading bundler setup
|
|
605
|
+
process.environment['OPENC3_SCOPE'] = scope
|
|
606
|
+
process.environment['RAILS_ROOT'] = RAILS_ROOT
|
|
564
607
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
608
|
+
process.detach = true
|
|
609
|
+
process.start
|
|
610
|
+
running_script_id
|
|
611
|
+
rescue => e
|
|
612
|
+
begin
|
|
613
|
+
# Clean up the orphaned script status record so the script
|
|
614
|
+
# does not remain stuck in 'spawning' state forever.
|
|
615
|
+
# Guard against nil in case the error occurred before these were assigned.
|
|
616
|
+
script_status.destroy() if script_status
|
|
617
|
+
FileUtils.rm_rf(script_cwd) if script_cwd
|
|
618
|
+
rescue => cleanup_error
|
|
619
|
+
OpenC3::Logger.error("Failed to cleanup after spawn failure: #{cleanup_error.message}")
|
|
620
|
+
end
|
|
621
|
+
raise e
|
|
622
|
+
end
|
|
568
623
|
end
|
|
569
624
|
|
|
570
625
|
def initialize(script_status)
|
|
@@ -601,7 +656,7 @@ class RunningScript
|
|
|
601
656
|
|
|
602
657
|
# Retrieve file
|
|
603
658
|
@body = ::Script.body(@script_status.scope, @script_status.filename)
|
|
604
|
-
raise "Script not found: #{@script_status.filename}" if @body.nil?
|
|
659
|
+
raise "Script not found during initialization: #{@script_status.filename}" if @body.nil?
|
|
605
660
|
breakpoints = @@breakpoints[@script_status.filename]&.filter { |_, present| present }&.map { |line_number, _| line_number - 1 } # -1 because frontend lines are 0-indexed
|
|
606
661
|
breakpoints ||= []
|
|
607
662
|
running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: @script_status.filename, scope: @script_status.scope, text: @body.to_utf8, breakpoints: breakpoints })
|
|
@@ -1476,7 +1531,7 @@ class RunningScript
|
|
|
1476
1531
|
running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
|
|
1477
1532
|
else
|
|
1478
1533
|
text = ::Script.body(@script_status.scope, filename)
|
|
1479
|
-
raise "Script not found: #{filename}" if text.nil?
|
|
1534
|
+
raise "Script not found during load/require: #{filename}" if text.nil?
|
|
1480
1535
|
@@file_cache[filename] = text
|
|
1481
1536
|
@body = text
|
|
1482
1537
|
running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
|