openc3 7.2.0 → 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 +108 -29
- data/bin/pipinstall +5 -4
- data/bin/pipuninstall +5 -1
- data/bin/uvinstall +149 -0
- data/data/config/interface_modifiers.yaml +15 -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 +38 -0
- data/data/config/tool.yaml +3 -1
- data/data/config/widgets.yaml +43 -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/binary_accessor.rb +4 -0
- data/lib/openc3/accessors/template_accessor.rb +2 -2
- data/lib/openc3/api/cmd_api.rb +5 -1
- data/lib/openc3/api/interface_api.rb +2 -2
- data/lib/openc3/api/limits_api.rb +89 -3
- 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/decom_common.rb +15 -4
- data/lib/openc3/microservices/decom_microservice.rb +6 -1
- data/lib/openc3/microservices/interface_microservice.rb +26 -12
- data/lib/openc3/microservices/queue_microservice.rb +4 -2
- data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
- data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
- data/lib/openc3/models/cvt_model.rb +7 -7
- data/lib/openc3/models/db_sharded_model.rb +2 -2
- data/lib/openc3/models/gem_model.rb +1 -1
- data/lib/openc3/models/interface_model.rb +17 -11
- data/lib/openc3/models/interface_status_model.rb +2 -2
- data/lib/openc3/models/metric_model.rb +2 -2
- data/lib/openc3/models/microservice_model.rb +21 -0
- data/lib/openc3/models/microservice_status_model.rb +2 -2
- data/lib/openc3/models/model.rb +2 -6
- data/lib/openc3/models/plugin_model.rb +204 -49
- data/lib/openc3/models/python_package_model.rb +210 -12
- data/lib/openc3/models/reingest_job_model.rb +1 -1
- data/lib/openc3/models/scope_model.rb +34 -30
- data/lib/openc3/models/script_status_model.rb +73 -38
- data/lib/openc3/models/target_model.rb +193 -6
- data/lib/openc3/models/tool_model.rb +33 -20
- data/lib/openc3/operators/microservice_operator.rb +33 -4
- data/lib/openc3/operators/operator.rb +15 -5
- data/lib/openc3/packets/commands.rb +7 -7
- data/lib/openc3/packets/limits.rb +33 -2
- data/lib/openc3/packets/packet.rb +6 -4
- data/lib/openc3/packets/packet_config.rb +5 -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 +16 -12
- 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/limits.rb +1 -1
- data/lib/openc3/script/suite_runner.rb +43 -9
- data/lib/openc3/script/web_socket_api.rb +45 -5
- data/lib/openc3/system/target.rb +4 -1
- data/lib/openc3/tools/table_manager/table_config.rb +4 -1
- data/lib/openc3/top_level.rb +1 -1
- data/lib/openc3/topics/command_decom_topic.rb +16 -1
- data/lib/openc3/topics/limits_event_topic.rb +63 -4
- data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
- data/lib/openc3/utilities/aws_bucket.rb +10 -2
- data/lib/openc3/utilities/cli_generator.rb +5 -1
- data/lib/openc3/utilities/csv.rb +5 -5
- data/lib/openc3/utilities/env_helper.rb +13 -1
- data/lib/openc3/utilities/local_mode.rb +4 -1
- data/lib/openc3/utilities/pypi_url.rb +39 -0
- data/lib/openc3/utilities/reingest_job.rb +1 -1
- data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
- data/lib/openc3/utilities/running_script.rb +160 -100
- data/lib/openc3/utilities/script.rb +60 -13
- data/lib/openc3/utilities/store_autoload.rb +39 -1
- data/lib/openc3/utilities/store_queued.rb +5 -1
- data/lib/openc3/version.rb +5 -5
- data/lib/openc3.rb +6 -1
- 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 +7 -3
- data/templates/tool_vue/.nycrc +0 -3
- data/templates/widget/.nycrc +0 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# encoding: ascii-8bit
|
|
2
|
+
|
|
3
|
+
# Copyright 2026 OpenC3, Inc.
|
|
4
|
+
# All Rights Reserved.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful,
|
|
7
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
8
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
9
|
+
# See LICENSE.md for more details.
|
|
10
|
+
#
|
|
11
|
+
# This file may also be used under the terms of a commercial license
|
|
12
|
+
# if purchased from OpenC3, Inc.
|
|
13
|
+
|
|
14
|
+
require 'uri'
|
|
15
|
+
require 'openc3/utilities/logger'
|
|
16
|
+
|
|
17
|
+
module OpenC3
|
|
18
|
+
class PypiUrl
|
|
19
|
+
DEFAULT = 'https://pypi.org/simple'
|
|
20
|
+
|
|
21
|
+
# Validate that a resolved pypi_url is an http(s) URL before it is handed to
|
|
22
|
+
# pip. The value can come from a user-writable setting or ENV, so a malformed
|
|
23
|
+
# or non-http value is rejected and replaced with the default rather than
|
|
24
|
+
# passed through to pip.
|
|
25
|
+
#
|
|
26
|
+
# @param pypi_url [String] the resolved pypi index url to validate
|
|
27
|
+
# @return [String] the original url if valid, otherwise DEFAULT
|
|
28
|
+
def self.validate(pypi_url)
|
|
29
|
+
uri = URI.parse(pypi_url)
|
|
30
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
31
|
+
raise URI::InvalidURIError, "not an http(s) URL"
|
|
32
|
+
end
|
|
33
|
+
pypi_url
|
|
34
|
+
rescue URI::InvalidURIError => e
|
|
35
|
+
Logger.error("Invalid pypi_url '#{pypi_url}' (#{e.message}); falling back to #{DEFAULT}")
|
|
36
|
+
DEFAULT
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -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 })
|
|
@@ -827,17 +882,22 @@ class RunningScript
|
|
|
827
882
|
|
|
828
883
|
def run
|
|
829
884
|
if @script_status.suite_runner
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
885
|
+
# Normalize the received hash through the shared helper so options/method
|
|
886
|
+
# defaults and validation match what openc3cli and the GUI construct.
|
|
887
|
+
sr = OpenC3::SuiteRunner.build_options(
|
|
888
|
+
suite: @script_status.suite_runner['suite'],
|
|
889
|
+
group: @script_status.suite_runner['group'],
|
|
890
|
+
script: @script_status.suite_runner['script'],
|
|
891
|
+
method: @script_status.suite_runner['method'],
|
|
892
|
+
options: @script_status.suite_runner['options'],
|
|
893
|
+
)
|
|
894
|
+
parse_options(sr['options'])
|
|
895
|
+
if sr['script']
|
|
896
|
+
run_text("OpenC3::SuiteRunner.start(#{sr['suite']}, #{sr['group']}, '#{sr['script']}')", initial_filename: "SCRIPTRUNNER")
|
|
897
|
+
elsif sr['group']
|
|
898
|
+
run_text("OpenC3::SuiteRunner.#{sr['method']}(#{sr['suite']}, #{sr['group']})", initial_filename: "SCRIPTRUNNER")
|
|
839
899
|
else
|
|
840
|
-
run_text("OpenC3::SuiteRunner.#{
|
|
900
|
+
run_text("OpenC3::SuiteRunner.#{sr['method']}(#{sr['suite']})", initial_filename: "SCRIPTRUNNER")
|
|
841
901
|
end
|
|
842
902
|
else
|
|
843
903
|
if not @script_engine and (@script_status.start_line_no != 1 or !@script_status.end_line_no.nil?)
|
|
@@ -1471,7 +1531,7 @@ class RunningScript
|
|
|
1471
1531
|
running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
|
|
1472
1532
|
else
|
|
1473
1533
|
text = ::Script.body(@script_status.scope, filename)
|
|
1474
|
-
raise "Script not found: #{filename}" if text.nil?
|
|
1534
|
+
raise "Script not found during load/require: #{filename}" if text.nil?
|
|
1475
1535
|
@@file_cache[filename] = text
|
|
1476
1536
|
@body = text
|
|
1477
1537
|
running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
|
|
@@ -25,33 +25,69 @@ require 'openc3/tools/test_runner/test'
|
|
|
25
25
|
OpenC3.require_file 'openc3/utilities/store'
|
|
26
26
|
|
|
27
27
|
class Script < OpenC3::TargetFile
|
|
28
|
+
# Script lifecycle states and the transitions allowed from each state
|
|
29
|
+
LIFECYCLE_STATES = %w(development review approved)
|
|
30
|
+
LIFECYCLE_TRANSITIONS = {
|
|
31
|
+
'development' => %w(review approved),
|
|
32
|
+
'review' => %w(development approved),
|
|
33
|
+
'approved' => %w(review development),
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
def self.all(scope, target = nil)
|
|
29
37
|
super(scope, nil, target: target) # No path matchers
|
|
30
38
|
end
|
|
31
39
|
|
|
40
|
+
# Split off the '*' that indicates a file is modified on the server
|
|
41
|
+
def self.strip_modified(name)
|
|
42
|
+
name.split('*')[0]
|
|
43
|
+
end
|
|
44
|
+
private_class_method :strip_modified
|
|
45
|
+
|
|
32
46
|
def self.lock(scope, name, username)
|
|
33
|
-
|
|
34
|
-
OpenC3::Store.hset("#{scope}__script-locks", name, username)
|
|
47
|
+
OpenC3::Store.hset("#{scope}__script-locks", strip_modified(name), username)
|
|
35
48
|
end
|
|
36
49
|
|
|
37
50
|
def self.unlock(scope, name)
|
|
38
|
-
|
|
39
|
-
OpenC3::Store.hdel("#{scope}__script-locks", name)
|
|
51
|
+
OpenC3::Store.hdel("#{scope}__script-locks", strip_modified(name))
|
|
40
52
|
end
|
|
41
53
|
|
|
42
54
|
def self.locked?(scope, name)
|
|
43
|
-
|
|
44
|
-
locked_by = OpenC3::Store.hget("#{scope}__script-locks", name)
|
|
55
|
+
locked_by = OpenC3::Store.hget("#{scope}__script-locks", strip_modified(name))
|
|
45
56
|
locked_by ||= false
|
|
46
57
|
locked_by
|
|
47
58
|
end
|
|
48
59
|
|
|
49
60
|
def self.get_breakpoints(scope, name)
|
|
50
|
-
breakpoints = OpenC3::Store.hget("#{scope}__script-breakpoints", name
|
|
61
|
+
breakpoints = OpenC3::Store.hget("#{scope}__script-breakpoints", strip_modified(name))
|
|
51
62
|
return JSON.parse(breakpoints, allow_nan: true, create_additions: true) if breakpoints
|
|
52
63
|
[]
|
|
53
64
|
end
|
|
54
65
|
|
|
66
|
+
def self.temp_file?(name)
|
|
67
|
+
strip_modified(name).start_with?("#{TEMP_FOLDER}/")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.lifecycle_enabled?
|
|
71
|
+
defined?(::VersionStore) && ::VersionStore.enabled?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.lifecycle(scope, name)
|
|
75
|
+
return { 'state' => 'development', 'history' => [] } if temp_file?(name)
|
|
76
|
+
return { 'state' => 'development', 'history' => [] } unless lifecycle_enabled?
|
|
77
|
+
::VersionStore.lifecycle(scope: scope, name: strip_modified(name))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# current: the caller-known current state; pass it to avoid a redundant git
|
|
81
|
+
# read when the controller has already fetched it for transition validation.
|
|
82
|
+
def self.set_lifecycle(scope, name, state, username, comment, current: nil)
|
|
83
|
+
raise "Cannot set lifecycle on temporary files" if temp_file?(name)
|
|
84
|
+
raise "Script lifecycle requires the version history store" unless lifecycle_enabled?
|
|
85
|
+
name = strip_modified(name)
|
|
86
|
+
current ||= lifecycle(scope, name)['state']
|
|
87
|
+
::VersionStore.set_lifecycle(scope: scope, name: name, from: current, to: state,
|
|
88
|
+
username: username, comment: comment)
|
|
89
|
+
end
|
|
90
|
+
|
|
55
91
|
def self.process_suite(name, contents, new_process: true, username: nil, scope:)
|
|
56
92
|
python = false
|
|
57
93
|
python = true if File.extname(name) == '.py'
|
|
@@ -139,16 +175,23 @@ class Script < OpenC3::TargetFile
|
|
|
139
175
|
process.io.stdout = stdout
|
|
140
176
|
process.io.stderr = stderr
|
|
141
177
|
process.start
|
|
142
|
-
|
|
178
|
+
begin
|
|
179
|
+
process.poll_for_exit(10) # wait for max 10s
|
|
180
|
+
rescue ChildProcess::TimeoutError
|
|
181
|
+
process.stop
|
|
182
|
+
stderr_results = "Suite analysis timed out - possible infinite loop in script\n"
|
|
183
|
+
success = false
|
|
184
|
+
end
|
|
143
185
|
stdout.rewind
|
|
144
186
|
stdout_results = stdout.read
|
|
145
187
|
stdout.close
|
|
146
188
|
stdout.unlink
|
|
147
189
|
stderr.rewind
|
|
148
|
-
stderr_results
|
|
190
|
+
stderr_results ||= ""
|
|
191
|
+
stderr_results += stderr.read
|
|
149
192
|
stderr.close
|
|
150
193
|
stderr.unlink
|
|
151
|
-
success = process.exit_code == 0
|
|
194
|
+
success = process.exit_code == 0 if success
|
|
152
195
|
else
|
|
153
196
|
require temp.path
|
|
154
197
|
stdout_results = OpenC3::SuiteRunner.build_suites.as_json().to_json(allow_nan: true)
|
|
@@ -182,7 +225,6 @@ class Script < OpenC3::TargetFile
|
|
|
182
225
|
def self.delete_temp(scope)
|
|
183
226
|
files = super(scope)
|
|
184
227
|
files.each do |name|
|
|
185
|
-
# Remove any breakpoints associated with the temp files
|
|
186
228
|
OpenC3::Store.hdel("#{scope}__script-breakpoints", "#{TEMP_FOLDER}/#{File.basename(name)}")
|
|
187
229
|
end
|
|
188
230
|
end
|
|
@@ -201,9 +243,14 @@ class Script < OpenC3::TargetFile
|
|
|
201
243
|
user_full_name = nil,
|
|
202
244
|
username = nil,
|
|
203
245
|
line_no = nil,
|
|
204
|
-
end_line_no = nil
|
|
246
|
+
end_line_no = nil,
|
|
247
|
+
python_venv = nil
|
|
205
248
|
)
|
|
206
|
-
|
|
249
|
+
# Verify the script exists before spawning a run. Without this check a run
|
|
250
|
+
# is started and the missing file only surfaces as a runtime error inside
|
|
251
|
+
# the spawned process. Returning nil lets the caller return a 404.
|
|
252
|
+
return nil unless Script.body(scope, name)
|
|
253
|
+
RunningScript.spawn(scope, name, suite_runner, disconnect, environment, user_full_name, username, line_no, end_line_no, python_venv)
|
|
207
254
|
end
|
|
208
255
|
|
|
209
256
|
def self.instrumented(filename, text)
|
|
@@ -123,8 +123,46 @@ module OpenC3
|
|
|
123
123
|
@redis_pool = StoreConnectionPool.new(size: pool_size) { build_redis() }
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# cap/base for the equal-jitter reconnect backoff (seconds)
|
|
127
|
+
REDIS_BACKOFF_CAP = 5.0
|
|
128
|
+
REDIS_BACKOFF_BASE = 0.625
|
|
129
|
+
|
|
126
130
|
def build_redis
|
|
127
|
-
|
|
131
|
+
# reconnect_attempts retries the connection a few times with equal-jitter
|
|
132
|
+
# backoff so a transient network blip is handled inside the client instead
|
|
133
|
+
# of immediately surfacing a connection error to callers. The jitter
|
|
134
|
+
# de-syncs many clients retrying the same blip to avoid a thundering herd
|
|
135
|
+
# on recovery.
|
|
136
|
+
#
|
|
137
|
+
# This mirrors the Python store's Retry(EqualJitterBackoff(cap: 5, base:
|
|
138
|
+
# 0.625), 3): per-retry backoff tops out at 5s on the final (3rd) retry
|
|
139
|
+
# (~0.6-1.25s, ~1.25-2.5s, ~2.5-5s). redis-rb takes a fixed Array of delays
|
|
140
|
+
# (no per-failure backoff callable), so we sample the jittered delays once
|
|
141
|
+
# per connection here.
|
|
142
|
+
# Connection, read, and write timeouts are left as the default: 1s
|
|
143
|
+
return Redis.new(
|
|
144
|
+
url: @redis_url,
|
|
145
|
+
username: @redis_username,
|
|
146
|
+
password: @redis_key,
|
|
147
|
+
reconnect_attempts: reconnect_backoff_delays()
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Equal-jitter backoff delays for 3 retries, matching Python's
|
|
152
|
+
# EqualJitterBackoff. Each retry's delay is randomized within the upper half
|
|
153
|
+
# of an exponentially-growing ceiling.
|
|
154
|
+
def reconnect_backoff_delays
|
|
155
|
+
(1..3).map do |failures|
|
|
156
|
+
# ceiling = exponential growth (base doubles each retry), clamped to cap.
|
|
157
|
+
# For base=0.625, cap=5: failures 1,2,3 -> 1.25, 2.5, 5.0 seconds.
|
|
158
|
+
# temp = half the ceiling: the guaranteed minimum wait for this retry.
|
|
159
|
+
temp = [REDIS_BACKOFF_CAP, REDIS_BACKOFF_BASE * (2 ** failures)].min / 2.0
|
|
160
|
+
# Final delay = fixed half (temp) + random half (rand*temp, in [0, temp)),
|
|
161
|
+
# i.e. a value uniformly in [temp, 2*temp) = [ceiling/2, ceiling).
|
|
162
|
+
# The fixed half keeps a sane floor; the random half de-syncs clients so
|
|
163
|
+
# they don't all reconnect in lockstep (thundering herd) after a blip.
|
|
164
|
+
temp + rand * temp
|
|
165
|
+
end
|
|
128
166
|
end
|
|
129
167
|
|
|
130
168
|
###########################################################################
|
|
@@ -103,7 +103,11 @@ module OpenC3
|
|
|
103
103
|
OpenC3.kill_thread(self, @update_thread) if @update_thread
|
|
104
104
|
@update_thread = nil
|
|
105
105
|
# Drain the queue before shutdown
|
|
106
|
-
|
|
106
|
+
begin
|
|
107
|
+
process_queue()
|
|
108
|
+
rescue
|
|
109
|
+
# Best effort - Redis may already be unavailable during shutdown
|
|
110
|
+
end
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
MessageStruct = Struct.new(:message, :args, :kwargs, :block)
|
data/lib/openc3/version.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# encoding: ascii-8bit
|
|
2
2
|
|
|
3
|
-
OPENC3_VERSION = '7.
|
|
3
|
+
OPENC3_VERSION = '7.3.0'
|
|
4
4
|
module OpenC3
|
|
5
5
|
module Version
|
|
6
6
|
MAJOR = '7'
|
|
7
|
-
MINOR = '
|
|
7
|
+
MINOR = '3'
|
|
8
8
|
PATCH = '0'
|
|
9
9
|
OTHER = ''
|
|
10
|
-
BUILD = '
|
|
10
|
+
BUILD = 'ec6ac203728234fada5e3a2ecab59fba8a94b935'
|
|
11
11
|
end
|
|
12
|
-
VERSION = '7.
|
|
13
|
-
GEM_VERSION = '7.
|
|
12
|
+
VERSION = '7.3.0'
|
|
13
|
+
GEM_VERSION = '7.3.0'
|
|
14
14
|
end
|
data/lib/openc3.rb
CHANGED
|
@@ -19,7 +19,12 @@ require 'resolv-replace'
|
|
|
19
19
|
# Set default encodings
|
|
20
20
|
saved_verbose = $VERBOSE; $VERBOSE = nil
|
|
21
21
|
Encoding.default_external = Encoding::ASCII_8BIT
|
|
22
|
-
Encoding.default_internal =
|
|
22
|
+
# NOTE: Do NOT set Encoding.default_internal = ASCII_8BIT. Doing so makes every
|
|
23
|
+
# read that specifies an encoding (e.g. File.read(path, encoding: 'UTF-8'))
|
|
24
|
+
# transcode into ASCII-8BIT, which raises Encoding::UndefinedConversionError on
|
|
25
|
+
# any non-ASCII byte. On Ruby 3.4 this breaks bootsnap's compile cache (it reads
|
|
26
|
+
# source as UTF-8 to work around Ruby bug #22023) for any file containing
|
|
27
|
+
# non-ASCII characters, including stdlib files like 'matrix'. Leave it nil.
|
|
23
28
|
$VERBOSE = saved_verbose
|
|
24
29
|
|
|
25
30
|
# Add OpenC3 bin folder to PATH
|
|
@@ -23,7 +23,7 @@ Gem::Specification.new do |s|
|
|
|
23
23
|
s.files = Dir.glob("{targets,lib,public,tools,microservices}/**/*") + %w(Rakefile README.md LICENSE.md plugin.txt)
|
|
24
24
|
|
|
25
25
|
s.metadata = {
|
|
26
|
-
# These fields are used when you submit your plugin to
|
|
26
|
+
# These fields are used when you submit your plugin to the OpenC3 Store at store.openc3.com
|
|
27
27
|
# See this help page for more detail: https://store.openc3.com/help/guidelines
|
|
28
28
|
"source_code_uri" => "https://github.com/your-github/plugin-repo",
|
|
29
29
|
"openc3_store_title" => "<%= plugin_orig %>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "<%= tool_name %>",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"ng": "ng",
|
|
6
6
|
"start": "ng serve",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@angular/platform-browser-dynamic": "^18.2.6",
|
|
24
24
|
"@angular/router": "^18.2.6",
|
|
25
25
|
"@astrouxds/astro-web-components": "^7.24.0",
|
|
26
|
-
"@openc3/js-common": "7.
|
|
26
|
+
"@openc3/js-common": "7.3.0",
|
|
27
27
|
"rxjs": "~7.8.0",
|
|
28
28
|
"single-spa": "^5.9.5",
|
|
29
29
|
"single-spa-angular": "^9.2.0",
|