openc3 7.1.1 → 7.2.1
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 +11 -23
- data/data/config/command_modifiers.yaml +2 -2
- data/data/config/interface_modifiers.yaml +15 -0
- data/data/config/item_modifiers.yaml +10 -3
- data/data/config/target.yaml +17 -0
- data/data/config/widgets.yaml +28 -1
- data/lib/openc3/accessors/binary_accessor.rb +4 -0
- data/lib/openc3/api/cmd_api.rb +5 -1
- data/lib/openc3/api/limits_api.rb +72 -0
- data/lib/openc3/api/tlm_api.rb +6 -0
- 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 +25 -11
- data/lib/openc3/microservices/microservice.rb +20 -5
- data/lib/openc3/microservices/queue_microservice.rb +4 -2
- data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
- data/lib/openc3/models/cvt_model.rb +2 -2
- data/lib/openc3/models/db_sharded_model.rb +2 -2
- data/lib/openc3/models/interface_model.rb +16 -10
- data/lib/openc3/models/interface_status_model.rb +2 -2
- data/lib/openc3/models/metric_model.rb +2 -2
- 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 +15 -14
- data/lib/openc3/models/python_package_model.rb +3 -1
- data/lib/openc3/models/reingest_job_model.rb +1 -1
- data/lib/openc3/models/script_status_model.rb +73 -38
- data/lib/openc3/models/target_model.rb +13 -0
- data/lib/openc3/operators/operator.rb +49 -14
- data/lib/openc3/packets/commands.rb +3 -3
- data/lib/openc3/packets/limits.rb +23 -0
- data/lib/openc3/packets/packet.rb +5 -3
- data/lib/openc3/packets/packet_config.rb +22 -5
- data/lib/openc3/packets/structure.rb +14 -10
- data/lib/openc3/script/limits.rb +1 -1
- data/lib/openc3/script/suite.rb +1 -1
- data/lib/openc3/script/suite_runner.rb +43 -9
- data/lib/openc3/script/web_socket_api.rb +6 -2
- 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/limits_event_topic.rb +62 -3
- data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
- data/lib/openc3/utilities/cli_generator.rb +432 -404
- data/lib/openc3/utilities/env_helper.rb +13 -1
- data/lib/openc3/utilities/pypi_url.rb +39 -0
- data/lib/openc3/utilities/questdb_client.rb +51 -4
- data/lib/openc3/utilities/reingest_job.rb +1 -1
- data/lib/openc3/utilities/running_script.rb +58 -15
- data/lib/openc3/utilities/script.rb +14 -3
- data/lib/openc3/utilities/simulated_target.rb +4 -2
- 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/command_validator/command_validator.py +8 -10
- data/templates/command_validator/command_validator.rb +6 -9
- data/templates/plugin/LICENSE.md +16 -4
- 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 +3 -1
|
@@ -79,7 +79,7 @@ module OpenC3
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
# DB_Shard-aware create: writes to the store instance for the given db_shard.
|
|
82
|
-
def _db_sharded_create(db_shard, update: false, force: false, queued: false,
|
|
82
|
+
def _db_sharded_create(db_shard, update: false, force: false, queued: false, expire_seconds: nil)
|
|
83
83
|
db_shard_store = self.class.store.instance(db_shard: db_shard)
|
|
84
84
|
unless force
|
|
85
85
|
existing = db_shard_store.hget(@primary_key, @name)
|
|
@@ -89,7 +89,7 @@ module OpenC3
|
|
|
89
89
|
raise RuntimeError.new("#{@primary_key}:#{@name} doesn't exist at update") if update
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
|
-
@updated_at =
|
|
92
|
+
@updated_at = Time.now.utc.to_nsec_from_epoch
|
|
93
93
|
|
|
94
94
|
if queued
|
|
95
95
|
store = self.class.store_queued.instance(db_shard: db_shard)
|
|
@@ -444,9 +444,11 @@ module OpenC3
|
|
|
444
444
|
microservice
|
|
445
445
|
end
|
|
446
446
|
|
|
447
|
-
#
|
|
448
|
-
#
|
|
449
|
-
#
|
|
447
|
+
# Destroys the deployed MicroserviceModel which triggers the operator to kill
|
|
448
|
+
# the microservice. Also directly destroys the Interface/RouterStatusModel in
|
|
449
|
+
# an ensure block. The microservice stop() no longer recreates the status
|
|
450
|
+
# model once cancel_thread is set, so undeploy must clean it up itself to
|
|
451
|
+
# avoid leaving an orphaned status model behind.
|
|
450
452
|
def undeploy
|
|
451
453
|
type = self.class._get_type
|
|
452
454
|
name = "#{@scope}__#{type}__#{@name}"
|
|
@@ -455,15 +457,19 @@ module OpenC3
|
|
|
455
457
|
model.destroy
|
|
456
458
|
ConfigTopic.write({ kind: 'deleted', type: type.downcase, name: @name, plugin: @plugin }, scope: @scope)
|
|
457
459
|
end
|
|
458
|
-
|
|
459
|
-
if type == 'INTERFACE'
|
|
460
|
-
status_model = InterfaceStatusModel.get_model(name: @name, scope: @scope)
|
|
461
|
-
else
|
|
462
|
-
status_model = RouterStatusModel.get_model(name: @name, scope: @scope)
|
|
463
|
-
end
|
|
464
|
-
status_model.destroy if status_model
|
|
465
460
|
rescue Exception => error
|
|
466
461
|
Logger.error("Error undeploying interface/router model #{@name} in scope #{@scope} due to #{error}")
|
|
462
|
+
ensure
|
|
463
|
+
begin
|
|
464
|
+
if type == 'INTERFACE'
|
|
465
|
+
status_model = InterfaceStatusModel.get_model(name: @name, scope: @scope)
|
|
466
|
+
else
|
|
467
|
+
status_model = RouterStatusModel.get_model(name: @name, scope: @scope)
|
|
468
|
+
end
|
|
469
|
+
status_model.destroy if status_model
|
|
470
|
+
rescue Exception => error
|
|
471
|
+
Logger.error("Error destroying #{type&.downcase || 'unknown'} status model #{@name} in scope #{@scope} due to #{error}")
|
|
472
|
+
end
|
|
467
473
|
end
|
|
468
474
|
|
|
469
475
|
def unmap_target(target_name, cmd_only: false, tlm_only: false)
|
|
@@ -118,8 +118,8 @@ module OpenC3
|
|
|
118
118
|
@rxcnt = rxcnt
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
def create(update: false, force: false, queued: false
|
|
122
|
-
_db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued
|
|
121
|
+
def create(update: false, force: false, queued: false)
|
|
122
|
+
_db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
def destroy
|
|
@@ -77,8 +77,8 @@ module OpenC3
|
|
|
77
77
|
@db_shard = db_shard.to_i
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
def create(update: false, force: false, queued: false,
|
|
81
|
-
_db_sharded_create(@db_shard, update: update, force: force, queued: queued,
|
|
80
|
+
def create(update: false, force: false, queued: false, expire_seconds: nil)
|
|
81
|
+
_db_sharded_create(@db_shard, update: update, force: force, queued: queued, expire_seconds: expire_seconds)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def destroy
|
|
@@ -76,8 +76,8 @@ module OpenC3
|
|
|
76
76
|
@custom = custom
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
def create(update: false, force: false, queued: false
|
|
80
|
-
_db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued
|
|
79
|
+
def create(update: false, force: false, queued: false)
|
|
80
|
+
_db_sharded_create(self.class._db_shard_for_name(@name, scope: @scope, use_cache: true), update: update, force: force, queued: queued)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def destroy
|
data/lib/openc3/models/model.rb
CHANGED
|
@@ -134,7 +134,7 @@ module OpenC3
|
|
|
134
134
|
|
|
135
135
|
# Update the Redis hash at primary_key and set the field "name"
|
|
136
136
|
# to the JSON generated via calling as_json
|
|
137
|
-
def create(update: false, force: false, queued: false
|
|
137
|
+
def create(update: false, force: false, queued: false)
|
|
138
138
|
unless force
|
|
139
139
|
existing = self.class.store.hget(@primary_key, @name)
|
|
140
140
|
if existing
|
|
@@ -143,11 +143,7 @@ module OpenC3
|
|
|
143
143
|
raise "#{@primary_key}:#{@name} doesn't exist at update" if update
|
|
144
144
|
end
|
|
145
145
|
end
|
|
146
|
-
|
|
147
|
-
@updated_at = Time.now.utc.iso8601
|
|
148
|
-
else
|
|
149
|
-
@updated_at = Time.now.utc.to_nsec_from_epoch
|
|
150
|
-
end
|
|
146
|
+
@updated_at = Time.now.utc.to_nsec_from_epoch
|
|
151
147
|
|
|
152
148
|
if queued
|
|
153
149
|
write_store = self.class.store_queued
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# encoding:
|
|
1
|
+
# encoding: utf-8
|
|
2
2
|
|
|
3
3
|
# Copyright 2022 Ball Aerospace & Technologies Corp.
|
|
4
4
|
# All Rights Reserved.
|
|
@@ -31,9 +31,11 @@ require 'openc3/models/tool_model'
|
|
|
31
31
|
require 'openc3/models/widget_model'
|
|
32
32
|
require 'openc3/models/microservice_model'
|
|
33
33
|
require 'openc3/api/api'
|
|
34
|
+
require 'openc3/utilities/pypi_url'
|
|
34
35
|
require 'tmpdir'
|
|
35
36
|
require 'tempfile'
|
|
36
37
|
require 'fileutils'
|
|
38
|
+
require 'open3'
|
|
37
39
|
|
|
38
40
|
module OpenC3
|
|
39
41
|
class EmptyGemFileError < StandardError; end
|
|
@@ -262,32 +264,31 @@ module OpenC3
|
|
|
262
264
|
if pypi_url
|
|
263
265
|
pypi_url += '/simple'
|
|
264
266
|
end
|
|
265
|
-
pypi_url ||=
|
|
267
|
+
pypi_url ||= PypiUrl::DEFAULT
|
|
266
268
|
end
|
|
267
269
|
end
|
|
270
|
+
pypi_url = PypiUrl.validate(pypi_url)
|
|
268
271
|
unless validate_only
|
|
272
|
+
# Build the pip arguments as an argv array and run pipinstall without a
|
|
273
|
+
# shell. pypi_url comes from a user-writable setting, so interpolating it
|
|
274
|
+
# into a shell command line allowed OS command injection. An argv array
|
|
275
|
+
# passes each argument verbatim with no shell metacharacter interpretation.
|
|
276
|
+
pip_args = ["-i", pypi_url]
|
|
277
|
+
pip_args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
|
|
269
278
|
if File.exist?(pyproject_path)
|
|
270
279
|
Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
|
|
271
|
-
|
|
272
|
-
pip_args = "-i #{pypi_url} #{gem_path}"
|
|
273
|
-
else
|
|
274
|
-
pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} #{gem_path}"
|
|
275
|
-
end
|
|
280
|
+
pip_args << gem_path
|
|
276
281
|
else
|
|
277
282
|
Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
|
|
278
|
-
|
|
279
|
-
pip_args = "-i #{pypi_url} -r #{requirements_path}"
|
|
280
|
-
else
|
|
281
|
-
pip_args = "-i #{pypi_url} --trusted-host #{URI.parse(pypi_url).host} -r #{requirements_path}"
|
|
282
|
-
end
|
|
283
|
+
pip_args += ["-r", requirements_path]
|
|
283
284
|
end
|
|
284
285
|
# Capture output and check exit code so failures surface as a warning
|
|
285
286
|
# rather than silently succeeding. pipinstall is non-fatal: the plugin
|
|
286
287
|
# continues to install even if Python packages fail so that non-Python
|
|
287
288
|
# functionality still works.
|
|
288
|
-
output =
|
|
289
|
+
output, status = Open3.capture2e("/openc3/bin/pipinstall", *pip_args)
|
|
289
290
|
puts output
|
|
290
|
-
unless
|
|
291
|
+
unless status.success?
|
|
291
292
|
Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
|
|
292
293
|
end
|
|
293
294
|
end
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
require 'fileutils'
|
|
15
15
|
require 'openc3/utilities/process_manager'
|
|
16
|
+
require 'openc3/utilities/pypi_url'
|
|
16
17
|
require 'openc3/api/api'
|
|
17
18
|
require 'pathname'
|
|
18
19
|
|
|
@@ -83,9 +84,10 @@ module OpenC3
|
|
|
83
84
|
if pypi_url
|
|
84
85
|
pypi_url += '/simple'
|
|
85
86
|
end
|
|
86
|
-
pypi_url ||=
|
|
87
|
+
pypi_url ||= PypiUrl::DEFAULT
|
|
87
88
|
end
|
|
88
89
|
end
|
|
90
|
+
pypi_url = PypiUrl.validate(pypi_url)
|
|
89
91
|
Logger.info "Installing python package: #{name_or_path}"
|
|
90
92
|
if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
|
|
91
93
|
pip_args = ["-i", pypi_url, package_file_path]
|
|
@@ -62,48 +62,83 @@ module OpenC3
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def self.all(scope:, offset: 0, limit: 10, type: "running")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
result = result.map do |r|
|
|
75
|
-
if r.nil?
|
|
76
|
-
nil
|
|
77
|
-
else
|
|
78
|
-
JSON.parse(r, allow_nan: true, create_additions: true)
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
return result
|
|
82
|
-
else
|
|
83
|
-
keys = self.store.zrevrange("#{COMPLETED_PRIMARY_KEY}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
|
|
84
|
-
return [] if keys.empty?
|
|
85
|
-
result = []
|
|
86
|
-
result = self.store.redis_pool.pipelined do
|
|
87
|
-
keys.each do |key|
|
|
88
|
-
self.store.hget("#{COMPLETED_PRIMARY_KEY}__#{scope}", key)
|
|
89
|
-
end
|
|
65
|
+
primary_key = (type == "running") ? RUNNING_PRIMARY_KEY : COMPLETED_PRIMARY_KEY
|
|
66
|
+
keys = self.store.zrevrange("#{primary_key}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
|
|
67
|
+
return [] if keys.empty?
|
|
68
|
+
|
|
69
|
+
result = self.store.redis_pool.pipelined do
|
|
70
|
+
keys.each do |key|
|
|
71
|
+
self.store.hget("#{primary_key}__#{scope}", key)
|
|
90
72
|
end
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
73
|
+
end
|
|
74
|
+
result = result.map do |r|
|
|
75
|
+
if r.nil?
|
|
76
|
+
nil
|
|
77
|
+
else
|
|
78
|
+
JSON.parse(r, allow_nan: true, create_additions: true)
|
|
97
79
|
end
|
|
98
|
-
return result
|
|
99
80
|
end
|
|
81
|
+
return result
|
|
100
82
|
end
|
|
101
83
|
|
|
102
84
|
def self.count(scope:, type: "running")
|
|
103
|
-
|
|
104
|
-
|
|
85
|
+
primary_key = (type == "running") ? RUNNING_PRIMARY_KEY : COMPLETED_PRIMARY_KEY
|
|
86
|
+
return self.store.zcount("#{primary_key}__#{scope}__LIST", 0, Float::INFINITY)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Fields that a search term is matched against (case-insensitive substring)
|
|
90
|
+
SEARCH_FIELDS = ['name', 'filename', 'username', 'user_full_name', 'state']
|
|
91
|
+
|
|
92
|
+
# Return a single page of items along with the total count of matching items.
|
|
93
|
+
# When searching, this fetches, parses, and filters the full list only once
|
|
94
|
+
# (instead of doing that work separately for the page and the count).
|
|
95
|
+
# Returns [page_items, total]
|
|
96
|
+
def self.page(scope:, offset: 0, limit: 10, type: "running", search: nil)
|
|
97
|
+
primary_key = (type == "running") ? RUNNING_PRIMARY_KEY : COMPLETED_PRIMARY_KEY
|
|
98
|
+
if search and !search.to_s.empty?
|
|
99
|
+
items = self.filter_by_search(self.fetch_all(primary_key, scope), search)
|
|
100
|
+
return [items[offset.to_i, limit.to_i] || [], items.length]
|
|
105
101
|
else
|
|
106
|
-
|
|
102
|
+
total = self.store.zcount("#{primary_key}__#{scope}__LIST", 0, Float::INFINITY)
|
|
103
|
+
keys = self.store.zrevrange("#{primary_key}__#{scope}__LIST", offset.to_i, offset.to_i + limit.to_i - 1)
|
|
104
|
+
page_items = keys.empty? ? [] : self.fetch(primary_key, scope, keys)
|
|
105
|
+
return [page_items, total]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Fetch and parse the hash values for the given keys, preserving their order
|
|
110
|
+
def self.fetch(primary_key, scope, keys)
|
|
111
|
+
result = self.store.redis_pool.pipelined do
|
|
112
|
+
keys.each do |key|
|
|
113
|
+
self.store.hget("#{primary_key}__#{scope}", key)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
result.map do |r|
|
|
117
|
+
# NOTE: create_additions is intentionally omitted here. ScriptStatusModel
|
|
118
|
+
# records are written via JSON.generate of as_json (plain hashes/arrays/
|
|
119
|
+
# primitives) and never contain a "json_class" marker, so create_additions
|
|
120
|
+
# has nothing to reconstruct. Enabling it disables the parser's fast path
|
|
121
|
+
# and makes this parse ~3x slower - costly since this runs over every
|
|
122
|
+
# record when listing/searching.
|
|
123
|
+
r.nil? ? nil : JSON.parse(r, allow_nan: true)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Fetch and parse every item for the type, in list order (most recent first)
|
|
128
|
+
def self.fetch_all(primary_key, scope)
|
|
129
|
+
keys = self.store.zrevrange("#{primary_key}__#{scope}__LIST", 0, -1)
|
|
130
|
+
return [] if keys.empty?
|
|
131
|
+
|
|
132
|
+
self.fetch(primary_key, scope, keys).compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Select items where any SEARCH_FIELDS value contains the search term (case-insensitive)
|
|
136
|
+
def self.filter_by_search(items, search)
|
|
137
|
+
search = search.to_s.downcase
|
|
138
|
+
items.select do |item|
|
|
139
|
+
next false if item.nil?
|
|
140
|
+
|
|
141
|
+
SEARCH_FIELDS.any? { |field| item[field].to_s.downcase.include?(search) }
|
|
107
142
|
end
|
|
108
143
|
end
|
|
109
144
|
|
|
@@ -175,7 +210,7 @@ module OpenC3
|
|
|
175
210
|
|
|
176
211
|
# Update the Redis hash at primary_key and set the field "name"
|
|
177
212
|
# to the JSON generated via calling as_json
|
|
178
|
-
def create(update: false, force: false, queued: false
|
|
213
|
+
def create(update: false, force: false, queued: false)
|
|
179
214
|
@updated_at = Time.now.utc.to_nsec_from_epoch
|
|
180
215
|
|
|
181
216
|
if queued
|
|
@@ -198,9 +233,9 @@ module OpenC3
|
|
|
198
233
|
|
|
199
234
|
# Move to completed
|
|
200
235
|
@primary_key = "#{COMPLETED_PRIMARY_KEY}__#{@scope}"
|
|
201
|
-
create(update: false, force: force, queued: queued
|
|
236
|
+
create(update: false, force: force, queued: queued)
|
|
202
237
|
else
|
|
203
|
-
create(update: true, force: force, queued: queued
|
|
238
|
+
create(update: true, force: force, queued: queued)
|
|
204
239
|
end
|
|
205
240
|
end
|
|
206
241
|
|
|
@@ -63,6 +63,7 @@ module OpenC3
|
|
|
63
63
|
attr_accessor :ignored_parameters
|
|
64
64
|
attr_accessor :ignored_items
|
|
65
65
|
attr_accessor :limits_groups
|
|
66
|
+
attr_accessor :stored_limits_mode
|
|
66
67
|
attr_accessor :cmd_tlm_files
|
|
67
68
|
attr_accessor :id
|
|
68
69
|
attr_accessor :cmd_buffer_depth
|
|
@@ -384,6 +385,7 @@ module OpenC3
|
|
|
384
385
|
ignored_parameters: [],
|
|
385
386
|
ignored_items: [],
|
|
386
387
|
limits_groups: [],
|
|
388
|
+
stored_limits_mode: 'PROCESS',
|
|
387
389
|
cmd_tlm_files: [],
|
|
388
390
|
id: nil,
|
|
389
391
|
updated_at: nil,
|
|
@@ -412,6 +414,8 @@ module OpenC3
|
|
|
412
414
|
@ignored_parameters = ignored_parameters
|
|
413
415
|
@ignored_items = ignored_items
|
|
414
416
|
@limits_groups = limits_groups
|
|
417
|
+
@stored_limits_mode = stored_limits_mode.to_s.upcase
|
|
418
|
+
@stored_limits_mode = 'PROCESS' unless %w(PROCESS LOG DISABLE).include?(@stored_limits_mode)
|
|
415
419
|
@cmd_tlm_files = cmd_tlm_files
|
|
416
420
|
@id = id
|
|
417
421
|
@cmd_buffer_depth = cmd_buffer_depth
|
|
@@ -442,6 +446,7 @@ module OpenC3
|
|
|
442
446
|
'ignored_parameters' => @ignored_parameters,
|
|
443
447
|
'ignored_items' => @ignored_items,
|
|
444
448
|
'limits_groups' => @limits_groups,
|
|
449
|
+
'stored_limits_mode' => @stored_limits_mode,
|
|
445
450
|
'cmd_tlm_files' => @cmd_tlm_files,
|
|
446
451
|
'id' => @id,
|
|
447
452
|
'updated_at' => @updated_at,
|
|
@@ -557,6 +562,14 @@ module OpenC3
|
|
|
557
562
|
parser.verify_num_parameters(1, 1, "#{keyword} <Shard Number Starting from 0>")
|
|
558
563
|
@db_shard = Integer(parameters[0])
|
|
559
564
|
|
|
565
|
+
when 'STORED_LIMITS_MODE'
|
|
566
|
+
parser.verify_num_parameters(1, 1, "#{keyword} <PROCESS, LOG, or DISABLE>")
|
|
567
|
+
mode = parameters[0].to_s.upcase
|
|
568
|
+
unless %w(PROCESS LOG DISABLE).include?(mode)
|
|
569
|
+
raise ConfigParser::Error.new(parser, "STORED_LIMITS_MODE must be one of PROCESS, LOG, or DISABLE")
|
|
570
|
+
end
|
|
571
|
+
@stored_limits_mode = mode
|
|
572
|
+
|
|
560
573
|
else
|
|
561
574
|
raise ConfigParser::Error.new(parser, "Unknown keyword and parameters for Target: #{keyword} #{parameters.join(" ")}")
|
|
562
575
|
end
|
|
@@ -238,6 +238,11 @@ module OpenC3
|
|
|
238
238
|
|
|
239
239
|
OperatorProcess.setup()
|
|
240
240
|
@cycle_time = (ENV['OPERATOR_CYCLE_TIME'] and ENV['OPERATOR_CYCLE_TIME'].to_f) || CYCLE_TIME # time in seconds
|
|
241
|
+
# Maximum number of new microservices to start per cycle. This spreads a
|
|
242
|
+
# large startup burst (e.g. installing a plugin with many targets) across
|
|
243
|
+
# multiple cycles so the shared services (object store, redis) aren't
|
|
244
|
+
# stampeded by every microservice connecting at once. 0 = no limit.
|
|
245
|
+
@max_start_per_cycle = (ENV['OPENC3_OPERATOR_MAX_START_PER_CYCLE'] || 5).to_i
|
|
241
246
|
|
|
242
247
|
@ruby_process_name = ENV['OPENC3_RUBY']
|
|
243
248
|
if RUBY_ENGINE != 'ruby'
|
|
@@ -262,10 +267,17 @@ module OpenC3
|
|
|
262
267
|
def start_new
|
|
263
268
|
@mutex.synchronize do
|
|
264
269
|
if @new_processes.length > 0
|
|
265
|
-
# Start
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
# Start at most @max_start_per_cycle processes this cycle; any
|
|
271
|
+
# remaining stay queued in @new_processes and start on later cycles.
|
|
272
|
+
# This avoids a startup stampede when many microservices appear at
|
|
273
|
+
# once (e.g. a plugin install) overwhelming the object store / redis.
|
|
274
|
+
start_names = @new_processes.keys
|
|
275
|
+
start_names = start_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
|
|
276
|
+
Logger.info("#{self.class} starting #{start_names.length} of #{@new_processes.length} new process(es)...")
|
|
277
|
+
start_names.each do |name|
|
|
278
|
+
@new_processes[name].start
|
|
279
|
+
@new_processes.delete(name)
|
|
280
|
+
end
|
|
269
281
|
end
|
|
270
282
|
end
|
|
271
283
|
end
|
|
@@ -273,12 +285,22 @@ module OpenC3
|
|
|
273
285
|
def respawn_changed
|
|
274
286
|
@mutex.synchronize do
|
|
275
287
|
if @changed_processes.length > 0
|
|
276
|
-
|
|
277
|
-
|
|
288
|
+
# Cycle at most @max_start_per_cycle changed microservices this cycle;
|
|
289
|
+
# any remaining stay queued in @changed_processes and are cycled on
|
|
290
|
+
# later cycles. This avoids a restart stampede when many microservices
|
|
291
|
+
# change at once (e.g. a configmap change) overwhelming shared
|
|
292
|
+
# services. Processes not yet cycled keep running until their turn.
|
|
293
|
+
cycle_names = @changed_processes.keys
|
|
294
|
+
cycle_names = cycle_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
|
|
295
|
+
cycle = @changed_processes.slice(*cycle_names)
|
|
296
|
+
Logger.info("Cycling #{cycle.length} of #{@changed_processes.length} changed microservices...")
|
|
297
|
+
shutdown_processes(cycle)
|
|
278
298
|
break if @shutdown
|
|
279
299
|
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
cycle_names.each do |name|
|
|
301
|
+
@changed_processes[name].start
|
|
302
|
+
@changed_processes.delete(name)
|
|
303
|
+
end
|
|
282
304
|
end
|
|
283
305
|
end
|
|
284
306
|
end
|
|
@@ -295,8 +317,11 @@ module OpenC3
|
|
|
295
317
|
|
|
296
318
|
def respawn_dead
|
|
297
319
|
@mutex.synchronize do
|
|
298
|
-
@processes.each do |
|
|
320
|
+
@processes.each do |name, p|
|
|
299
321
|
break if @shutdown
|
|
322
|
+
# Skip processes still queued by the per-cycle start limit; they
|
|
323
|
+
# haven't been started yet so they aren't "dead" to be respawned.
|
|
324
|
+
next if @new_processes[name]
|
|
300
325
|
p.output_increment
|
|
301
326
|
unless p.alive?
|
|
302
327
|
# Respawn process
|
|
@@ -356,11 +381,21 @@ module OpenC3
|
|
|
356
381
|
# Monitor processes and respawn if died
|
|
357
382
|
Logger.info("#{self.class} Monitoring processes every #{@cycle_time} sec...")
|
|
358
383
|
loop do
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
384
|
+
# A single cycle must never be able to take down the operator process.
|
|
385
|
+
# update() in particular hits Redis every cycle and a transient network
|
|
386
|
+
# error (the kind that also makes targets reconnect) would otherwise
|
|
387
|
+
# unwind run() and exit the process, which in the container looks like a
|
|
388
|
+
# full operator restart. Catch, log, and keep cycling so the next cycle
|
|
389
|
+
# can recover once Redis is reachable again.
|
|
390
|
+
begin
|
|
391
|
+
update()
|
|
392
|
+
remove_old()
|
|
393
|
+
respawn_changed()
|
|
394
|
+
start_new()
|
|
395
|
+
respawn_dead()
|
|
396
|
+
rescue => e
|
|
397
|
+
Logger.error("#{self.class} cycle error, continuing: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
|
398
|
+
end
|
|
364
399
|
break if @shutdown
|
|
365
400
|
|
|
366
401
|
sleep(@cycle_time)
|
|
@@ -325,10 +325,10 @@ module OpenC3
|
|
|
325
325
|
unless item.states.values.include?(value)
|
|
326
326
|
if command.raw
|
|
327
327
|
# Raw commands report missing value maps
|
|
328
|
-
raise "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.values.join(', ')}"
|
|
328
|
+
raise RangeError, "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.values.join(', ')}"
|
|
329
329
|
else
|
|
330
330
|
# Normal commands report missing state maps
|
|
331
|
-
raise "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.keys.join(', ')}"
|
|
331
|
+
raise RangeError, "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{value.to_s.upcase} not one of #{item.states.keys.join(', ')}"
|
|
332
332
|
end
|
|
333
333
|
end
|
|
334
334
|
end
|
|
@@ -340,7 +340,7 @@ module OpenC3
|
|
|
340
340
|
# Perform Range Check on command parameter
|
|
341
341
|
if not range.include?(range_check_value)
|
|
342
342
|
range_check_value = "'#{range_check_value}'" if String === range_check_value
|
|
343
|
-
raise "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{range_check_value} not in valid range of #{range.first} to #{range.last}"
|
|
343
|
+
raise RangeError, "Command parameter '#{command.target_name} #{command.packet_name} #{item_upcase}' = #{range_check_value} not in valid range of #{range.first} to #{range.last}"
|
|
344
344
|
end
|
|
345
345
|
end
|
|
346
346
|
end
|
|
@@ -153,6 +153,29 @@ module OpenC3
|
|
|
153
153
|
return [limits_set, limits.persistence_setting, limits.enabled, limits_for_set[0], limits_for_set[1], limits_for_set[2], limits_for_set[3], limits_for_set[4], limits_for_set[5]]
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
# Set the color for a telemetry item state
|
|
157
|
+
#
|
|
158
|
+
# @param target_name [String] Target Name
|
|
159
|
+
# @param packet_name [String] Packet Name
|
|
160
|
+
# @param item_name [String] Item Name
|
|
161
|
+
# @param state_name [String] State Name (e.g. 'CONNECTED')
|
|
162
|
+
# @param color [String or Symbol] New color: GREEN, YELLOW, or RED
|
|
163
|
+
# @return [Symbol] The color that was set
|
|
164
|
+
def set_state_color(target_name, packet_name, item_name, state_name, color)
|
|
165
|
+
packet = _get_packet(target_name, packet_name)
|
|
166
|
+
item = packet.get_item(item_name)
|
|
167
|
+
raise "Item #{target_name} #{packet_name} #{item_name} does not have any states" unless item.states
|
|
168
|
+
state_name = state_name.to_s.upcase
|
|
169
|
+
raise "State #{state_name} does not exist for #{target_name} #{packet_name} #{item_name}" unless item.states.key?(state_name)
|
|
170
|
+
color = color.to_s.upcase.intern
|
|
171
|
+
raise "Invalid state color #{color}. Must be one of #{PacketItem::STATE_COLORS.join(' ')}." unless PacketItem::STATE_COLORS.include?(color)
|
|
172
|
+
item.state_colors ||= {}
|
|
173
|
+
item.state_colors[state_name] = color
|
|
174
|
+
item.limits.enabled = true
|
|
175
|
+
packet.update_limits_items_cache(item)
|
|
176
|
+
return color
|
|
177
|
+
end
|
|
178
|
+
|
|
156
179
|
protected
|
|
157
180
|
|
|
158
181
|
def _get_packet(target_name, packet_name)
|
|
@@ -1285,7 +1285,7 @@ module OpenC3
|
|
|
1285
1285
|
config
|
|
1286
1286
|
end
|
|
1287
1287
|
|
|
1288
|
-
def decom
|
|
1288
|
+
def decom(include_limits_states: true)
|
|
1289
1289
|
# Read all the RAW at once because this could be optimized by the accessor
|
|
1290
1290
|
json_hash = read_items(@sorted_items)
|
|
1291
1291
|
|
|
@@ -1301,7 +1301,9 @@ module OpenC3
|
|
|
1301
1301
|
if item.format_string or item.units
|
|
1302
1302
|
json_hash["#{item.name}__F"] = read_item(item, :FORMATTED, @buffer, given_raw)
|
|
1303
1303
|
end
|
|
1304
|
-
|
|
1304
|
+
if include_limits_states
|
|
1305
|
+
limits_state = item.limits.state
|
|
1306
|
+
end
|
|
1305
1307
|
if limits_state
|
|
1306
1308
|
json_hash["#{item.name}__L"] = limits_state
|
|
1307
1309
|
end
|
|
@@ -1551,4 +1553,4 @@ module OpenC3
|
|
|
1551
1553
|
item
|
|
1552
1554
|
end
|
|
1553
1555
|
end
|
|
1554
|
-
end
|
|
1556
|
+
end
|
|
@@ -161,7 +161,11 @@ module OpenC3
|
|
|
161
161
|
process_target_name = process_target_name.upcase
|
|
162
162
|
parser = ConfigParser.new("https://docs.openc3.com/docs")
|
|
163
163
|
parser.instance_variable_set(:@target_name, process_target_name)
|
|
164
|
-
|
|
164
|
+
# run_erb is false: ERB is only rendered once, at plugin install time
|
|
165
|
+
# (TargetModel#deploy), before files are written to the targets bucket. The
|
|
166
|
+
# targets_modified overlay (e.g. dynamically created packets) is never ERB
|
|
167
|
+
# rendered, so it is treated as data here rather than executed as code.
|
|
168
|
+
parser.parse_file(filename, false, true, false) do |keyword, params|
|
|
165
169
|
if @building_generic_conversion
|
|
166
170
|
case keyword
|
|
167
171
|
# Complete a generic conversion
|
|
@@ -437,12 +441,25 @@ module OpenC3
|
|
|
437
441
|
if packet.id_items.length > 0
|
|
438
442
|
key = []
|
|
439
443
|
id_signature = ""
|
|
440
|
-
# Accessor class
|
|
441
|
-
# with different accessors
|
|
442
|
-
#
|
|
444
|
+
# Accessor class AND args are part of the signature so packets in the
|
|
445
|
+
# same target with different accessors -- or the same accessor class
|
|
446
|
+
# configured with different args -- trigger unique_id_mode. Different
|
|
447
|
+
# accessors (or same accessor, different args) decode the buffer
|
|
448
|
+
# differently, so the shared hash-lookup path is unsafe.
|
|
443
449
|
packet.id_items.each do |item|
|
|
444
450
|
key << item.id_value
|
|
445
|
-
id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}"
|
|
451
|
+
id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}__#{packet.accessor.args.inspect}"
|
|
452
|
+
# STRUCTURE-derived id_items are decoded by the parent's structure
|
|
453
|
+
# accessor (see Accessor#read_item), so include that accessor's class
|
|
454
|
+
# and args in the signature too.
|
|
455
|
+
if item.parent_item
|
|
456
|
+
parent = packet.get_item(item.parent_item)
|
|
457
|
+
structure = parent.structure
|
|
458
|
+
if structure
|
|
459
|
+
structure_accessor = structure.accessor
|
|
460
|
+
id_signature << "__#{structure_accessor.class.to_s}__#{structure_accessor.args.inspect}"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
446
463
|
end
|
|
447
464
|
target_id_value_hash[key] = packet
|
|
448
465
|
target_id_signature = id_signature_hash[packet.target_name]
|
|
@@ -604,19 +604,23 @@ module OpenC3
|
|
|
604
604
|
if item.variable_bit_size
|
|
605
605
|
# Bit size is determined by length field
|
|
606
606
|
length_value = self.read(item.variable_bit_size['length_item_name'], :CONVERTED)
|
|
607
|
-
if
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
607
|
+
if length_value
|
|
608
|
+
if (item.data_type == :INT or item.data_type == :UINT) and not item.original_array_size
|
|
609
|
+
case length_value
|
|
610
|
+
when 0
|
|
611
|
+
return 6
|
|
612
|
+
when 1
|
|
613
|
+
return 14
|
|
614
|
+
when 2
|
|
615
|
+
return 30
|
|
616
|
+
else
|
|
617
|
+
return 62
|
|
618
|
+
end
|
|
615
619
|
else
|
|
616
|
-
return
|
|
620
|
+
return (length_value * item.variable_bit_size['length_bits_per_count']) + item.variable_bit_size['length_value_bit_offset']
|
|
617
621
|
end
|
|
618
622
|
else
|
|
619
|
-
|
|
623
|
+
raise "Length value #{item.variable_bit_size['length_item_name']} for item #{item.name} is nil"
|
|
620
624
|
end
|
|
621
625
|
elsif item.original_bit_size <= 0
|
|
622
626
|
# Bit size is full packet length - bits before item + negative bits saved at end
|