openc3 7.4.0 → 7.5.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 +4 -3
- data/bin/pipinstall +18 -5
- data/bin/pipuninstall +1 -1
- data/bin/uvinstall +36 -8
- data/data/config/default_plugins.yaml +43 -0
- data/data/config/interface_modifiers.yaml +22 -7
- data/data/config/item_modifiers.yaml +14 -4
- data/data/config/plugins.yaml +1 -1
- data/data/config/widgets.yaml +1 -1
- data/lib/openc3/api/cmd_api.rb +10 -13
- data/lib/openc3/api/interface_api.rb +1 -2
- data/lib/openc3/api/limits_api.rb +1 -1
- data/lib/openc3/api/metrics_api.rb +26 -1
- data/lib/openc3/api/router_api.rb +1 -2
- data/lib/openc3/api/tlm_api.rb +21 -21
- data/lib/openc3/config/config_parser.rb +3 -1
- data/lib/openc3/core_ext/tempfile.rb +2 -0
- data/lib/openc3/interfaces/file_interface.rb +6 -1
- data/lib/openc3/interfaces/http_client_interface.rb +14 -6
- data/lib/openc3/interfaces/interface.rb +27 -3
- data/lib/openc3/interfaces/protocols/protocol.rb +8 -0
- data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
- data/lib/openc3/interfaces/stream_interface.rb +22 -3
- data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
- data/lib/openc3/interfaces/udp_interface.rb +16 -2
- data/lib/openc3/interfaces.rb +1 -0
- data/lib/openc3/io/json_api_object.rb +24 -3
- data/lib/openc3/io/json_drb_object.rb +3 -2
- data/lib/openc3/logs/log_writer.rb +13 -4
- data/lib/openc3/microservices/decom_microservice.rb +7 -4
- data/lib/openc3/microservices/interface_microservice.rb +146 -50
- data/lib/openc3/microservices/log_microservice.rb +1 -3
- data/lib/openc3/microservices/microservice.rb +25 -1
- data/lib/openc3/microservices/plugin_microservice.rb +32 -0
- data/lib/openc3/microservices/text_log_microservice.rb +1 -3
- data/lib/openc3/models/interface_model.rb +1 -1
- data/lib/openc3/models/interface_status_model.rb +6 -0
- data/lib/openc3/models/metric_model.rb +52 -4
- data/lib/openc3/models/microservice_model.rb +7 -3
- data/lib/openc3/models/plugin_model.rb +12 -12
- data/lib/openc3/models/python_package_model.rb +3 -13
- data/lib/openc3/models/reaction_model.rb +6 -3
- data/lib/openc3/models/setting_model.rb +5 -3
- data/lib/openc3/models/target_model.rb +83 -9
- data/lib/openc3/operators/microservice_operator.rb +5 -0
- data/lib/openc3/operators/operator.rb +69 -0
- data/lib/openc3/packets/packet.rb +10 -4
- data/lib/openc3/packets/packet_config.rb +48 -2
- data/lib/openc3/packets/packet_item.rb +27 -14
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
- data/lib/openc3/packets/structure_item.rb +3 -2
- data/lib/openc3/script/api_shared.rb +58 -84
- data/lib/openc3/script/extract.rb +215 -19
- data/lib/openc3/script/plugins.rb +4 -11
- data/lib/openc3/script/storage.rb +81 -10
- data/lib/openc3/script/tables.rb +5 -1
- data/lib/openc3/system/system.rb +9 -6
- data/lib/openc3/topics/command_decom_topic.rb +1 -1
- data/lib/openc3/topics/command_topic.rb +2 -1
- data/lib/openc3/topics/router_topic.rb +2 -1
- data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
- data/lib/openc3/topics/telemetry_topic.rb +3 -2
- data/lib/openc3/topics/topic.rb +154 -2
- data/lib/openc3/utilities/cli_generator.rb +67 -15
- data/lib/openc3/utilities/config_overlay.rb +35 -17
- data/lib/openc3/utilities/default_plugins.rb +33 -0
- data/lib/openc3/utilities/local_mode.rb +82 -46
- data/lib/openc3/utilities/metric.rb +16 -0
- data/lib/openc3/utilities/process_stats.rb +160 -0
- data/lib/openc3/utilities/pypi_url.rb +65 -0
- data/lib/openc3/utilities/questdb_client.rb +43 -46
- data/lib/openc3/utilities/running_script.rb +22 -18
- data/lib/openc3/utilities/store_autoload.rb +20 -3
- data/lib/openc3/utilities/store_queued.rb +37 -9
- data/lib/openc3/utilities/target_file.rb +57 -12
- data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
- data/lib/openc3/version.rb +5 -5
- data/templates/conversion/conversion.py +6 -2
- data/templates/conversion/conversion.rb +5 -0
- data/templates/plugin/.github/workflows/playwright.yml +62 -0
- data/templates/plugin/.github/workflows/release.yml +45 -0
- data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
- data/templates/plugin/.gitignore +6 -0
- data/templates/plugin/README.md +114 -2
- data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
- data/templates/plugin/plugin.gemspec +10 -1
- data/templates/plugin/pyproject.toml +119 -0
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_angular/pnpm-workspace.yaml +16 -0
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_react/pnpm-workspace.yaml +16 -0
- data/templates/tool_svelte/package.json +2 -1
- data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
- data/templates/tool_vue/package.json +3 -3
- data/templates/tool_vue/pnpm-workspace.yaml +16 -0
- data/templates/widget/package.json +2 -2
- data/templates/widget/pnpm-workspace.yaml +16 -0
- metadata +36 -1
|
@@ -23,6 +23,13 @@ module OpenC3
|
|
|
23
23
|
include DbShardedModel
|
|
24
24
|
|
|
25
25
|
PRIMARY_KEY = '__openc3__metric'.freeze
|
|
26
|
+
# Process level metrics (cpu, memory) reported by the operator on behalf of
|
|
27
|
+
# the microservices it spawned. These live in their own hash so that the
|
|
28
|
+
# operator and the microservice are never two writers of one field - each
|
|
29
|
+
# write replaces the whole values hash, so sharing a field would have them
|
|
30
|
+
# clobbering each other every cycle. The two are merged back together on
|
|
31
|
+
# read by get/names/all, so callers see a single row per microservice.
|
|
32
|
+
PROCESS_PRIMARY_KEY = '__openc3__process_metric'.freeze
|
|
26
33
|
METRIC_EXPIRE_SECONDS = 3600 # Expire metrics after 1 hour
|
|
27
34
|
|
|
28
35
|
attr_accessor :values
|
|
@@ -47,17 +54,48 @@ module OpenC3
|
|
|
47
54
|
# NOTE: The following three class methods are used by the ModelController
|
|
48
55
|
# and are reimplemented to enable various Model class methods to work
|
|
49
56
|
def self.get(name:, scope:)
|
|
50
|
-
|
|
57
|
+
_merge_entries(
|
|
58
|
+
_db_sharded_get("#{scope}#{PRIMARY_KEY}", name: name, scope: scope),
|
|
59
|
+
_db_sharded_get("#{scope}#{PROCESS_PRIMARY_KEY}", name: name, scope: scope)
|
|
60
|
+
)
|
|
51
61
|
end
|
|
52
62
|
|
|
53
63
|
def self.names(scope:)
|
|
54
|
-
_db_sharded_names("#{scope}#{PRIMARY_KEY}", scope: scope)
|
|
64
|
+
names = _db_sharded_names("#{scope}#{PRIMARY_KEY}", scope: scope)
|
|
65
|
+
names.concat(_db_sharded_names("#{scope}#{PROCESS_PRIMARY_KEY}", scope: scope))
|
|
66
|
+
names.uniq.sort
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
def self.all(scope:)
|
|
70
|
+
result = _db_sharded_all("#{scope}#{PRIMARY_KEY}", scope: scope)
|
|
71
|
+
_db_sharded_all("#{scope}#{PROCESS_PRIMARY_KEY}", scope: scope).each do |name, process_entry|
|
|
72
|
+
result[name] = _merge_entries(result[name], process_entry)
|
|
73
|
+
end
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only the metrics each microservice reported about itself, without the
|
|
78
|
+
# operator's process metrics. Their updated_at is when the microservice
|
|
79
|
+
# last published, which the merged rows from all can't tell you because
|
|
80
|
+
# the operator keeps refreshing them while the process is alive.
|
|
81
|
+
def self.all_self_reported(scope:)
|
|
58
82
|
_db_sharded_all("#{scope}#{PRIMARY_KEY}", scope: scope)
|
|
59
83
|
end
|
|
60
84
|
|
|
85
|
+
# Combine a microservice's self reported metrics with the process metrics
|
|
86
|
+
# the operator reported for it. The microservice's own values win on a key
|
|
87
|
+
# collision - a process that can measure itself knows better than the
|
|
88
|
+
# operator watching from outside.
|
|
89
|
+
def self._merge_entries(entry, process_entry)
|
|
90
|
+
return entry unless process_entry
|
|
91
|
+
return process_entry unless entry
|
|
92
|
+
|
|
93
|
+
entry['values'] = (process_entry['values'] || {}).merge(entry['values'] || {})
|
|
94
|
+
updated_at = [entry['updated_at'], process_entry['updated_at']].compact.max
|
|
95
|
+
entry['updated_at'] = updated_at if updated_at
|
|
96
|
+
entry
|
|
97
|
+
end
|
|
98
|
+
|
|
61
99
|
# Sets (updates) the redis hash of this model
|
|
62
100
|
# Queued defaults to true for MetricModel
|
|
63
101
|
def self.set(json, scope:, queued: true)
|
|
@@ -66,13 +104,23 @@ module OpenC3
|
|
|
66
104
|
self.new(**json).create(force: true, queued: queued, expire_seconds: METRIC_EXPIRE_SECONDS)
|
|
67
105
|
end
|
|
68
106
|
|
|
107
|
+
# Sets (updates) the process metrics reported by the operator for a
|
|
108
|
+
# microservice it spawned. See PROCESS_PRIMARY_KEY.
|
|
109
|
+
def self.set_process(json, scope:, queued: true)
|
|
110
|
+
json[:scope] = scope
|
|
111
|
+
json.transform_keys!(&:to_sym)
|
|
112
|
+
json[:primary_key] = PROCESS_PRIMARY_KEY
|
|
113
|
+
self.new(**json).create(force: true, queued: queued, expire_seconds: METRIC_EXPIRE_SECONDS)
|
|
114
|
+
end
|
|
115
|
+
|
|
69
116
|
def self.destroy(scope:, name:)
|
|
70
117
|
db_shard = _db_shard_for_name(name, scope: scope)
|
|
71
118
|
store.instance(db_shard: db_shard).hdel("#{scope}#{PRIMARY_KEY}", name)
|
|
119
|
+
store.instance(db_shard: db_shard).hdel("#{scope}#{PROCESS_PRIMARY_KEY}", name)
|
|
72
120
|
end
|
|
73
121
|
|
|
74
|
-
def initialize(name:, values: {}, db_shard: 0, scope:)
|
|
75
|
-
super("#{scope}#{
|
|
122
|
+
def initialize(name:, values: {}, db_shard: 0, scope:, primary_key: PRIMARY_KEY)
|
|
123
|
+
super("#{scope}#{primary_key}", name: name, scope: scope)
|
|
76
124
|
@values = values
|
|
77
125
|
@db_shard = db_shard.to_i
|
|
78
126
|
end
|
|
@@ -308,9 +308,13 @@ module OpenC3
|
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
def cleanup
|
|
311
|
-
# Cleanup metrics
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
# Cleanup metrics - both the microservice's own and the process metrics
|
|
312
|
+
# the operator reported for it
|
|
313
|
+
# The MicroserviceModel has already been deleted during uninstall, so
|
|
314
|
+
# shard lookup by name can no longer find its configured database.
|
|
315
|
+
[MetricModel::PRIMARY_KEY, MetricModel::PROCESS_PRIMARY_KEY].each do |key|
|
|
316
|
+
MetricModel.new(name: @name, scope: @scope, db_shard: @db_shard, primary_key: key).destroy
|
|
317
|
+
end
|
|
314
318
|
end
|
|
315
319
|
end
|
|
316
320
|
end
|
|
@@ -278,7 +278,10 @@ module OpenC3
|
|
|
278
278
|
|
|
279
279
|
if File.exist?(pyproject_path) || File.exist?(requirements_path)
|
|
280
280
|
pypi_url = resolve_pypi_url(scope: scope)
|
|
281
|
-
|
|
281
|
+
# Skipped for any dry run: diff_only runs inline in an HTTP request, so
|
|
282
|
+
# a slow or unreachable PyPI would time it out, and it must not mutate
|
|
283
|
+
# the plugin venv.
|
|
284
|
+
unless dry_run
|
|
282
285
|
pypi_args = build_pypi_args(pypi_url)
|
|
283
286
|
|
|
284
287
|
# Install Python dependencies into an isolated per-plugin venv when UV
|
|
@@ -428,11 +431,9 @@ module OpenC3
|
|
|
428
431
|
# Dry run: which modified files would this plugin's install supersede?
|
|
429
432
|
# Returns a list of "TARGET/path" names whose live (modified) content
|
|
430
433
|
# differs from the rendered plugin content. Read-only; no side effects.
|
|
434
|
+
# Errors propagate so callers can tell a failed check from an empty diff.
|
|
431
435
|
def self.modified_diff(plugin_hash, scope:)
|
|
432
436
|
install_phase2(plugin_hash, scope: scope, diff_only: true)
|
|
433
|
-
rescue => e
|
|
434
|
-
Logger.warn("PluginModel.modified_diff failed: #{e.message}")
|
|
435
|
-
[]
|
|
436
437
|
end
|
|
437
438
|
|
|
438
439
|
def initialize(
|
|
@@ -597,25 +598,24 @@ module OpenC3
|
|
|
597
598
|
def self.resolve_pypi_url(scope:)
|
|
598
599
|
pypi_url = nil
|
|
599
600
|
begin
|
|
600
|
-
pypi_url = get_setting('pypi_url', scope: scope)
|
|
601
|
-
pypi_url += '/simple' if pypi_url
|
|
601
|
+
pypi_url = PypiUrl.index_url(get_setting('pypi_url', scope: scope))
|
|
602
602
|
rescue => e
|
|
603
603
|
Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
|
|
604
604
|
ensure
|
|
605
605
|
if pypi_url.nil?
|
|
606
|
-
pypi_url = ENV.fetch('PYPI_URL', nil)
|
|
607
|
-
pypi_url += '/simple' if pypi_url
|
|
606
|
+
pypi_url = PypiUrl.index_url(ENV.fetch('PYPI_URL', nil))
|
|
608
607
|
pypi_url ||= PypiUrl::DEFAULT
|
|
609
608
|
end
|
|
610
609
|
end
|
|
611
610
|
PypiUrl.validate(pypi_url)
|
|
612
611
|
end
|
|
613
612
|
|
|
614
|
-
# Build the argv array
|
|
613
|
+
# Build the argv array of pypi index arguments. Kept as a delegate so
|
|
614
|
+
# callers that already reference PluginModel keep working; the logic lives
|
|
615
|
+
# in PypiUrl because PythonPackageModel needs it too and must not have to
|
|
616
|
+
# require this file.
|
|
615
617
|
def self.build_pypi_args(pypi_url)
|
|
616
|
-
|
|
617
|
-
args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
|
|
618
|
-
args
|
|
618
|
+
PypiUrl.build_args(pypi_url)
|
|
619
619
|
end
|
|
620
620
|
|
|
621
621
|
# Build a sanitized venv directory name from scope and plugin name.
|
|
@@ -235,29 +235,19 @@ module OpenC3
|
|
|
235
235
|
end
|
|
236
236
|
package_filename = File.basename(package_file_path)
|
|
237
237
|
begin
|
|
238
|
-
pypi_url = get_setting('pypi_url', scope: scope)
|
|
239
|
-
if pypi_url
|
|
240
|
-
pypi_url += '/simple'
|
|
241
|
-
end
|
|
238
|
+
pypi_url = PypiUrl.index_url(get_setting('pypi_url', scope: scope))
|
|
242
239
|
rescue => e
|
|
243
240
|
Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
|
|
244
241
|
ensure
|
|
245
242
|
if pypi_url.nil?
|
|
246
243
|
# If Redis isn't running try the ENV, then simply pypi.org/simple
|
|
247
|
-
pypi_url = ENV.fetch('PYPI_URL', nil)
|
|
248
|
-
if pypi_url
|
|
249
|
-
pypi_url += '/simple'
|
|
250
|
-
end
|
|
244
|
+
pypi_url = PypiUrl.index_url(ENV.fetch('PYPI_URL', nil))
|
|
251
245
|
pypi_url ||= PypiUrl::DEFAULT
|
|
252
246
|
end
|
|
253
247
|
end
|
|
254
248
|
pypi_url = PypiUrl.validate(pypi_url)
|
|
255
249
|
Logger.info "Installing python package: #{name_or_path}"
|
|
256
|
-
|
|
257
|
-
pip_args = ["-i", pypi_url, package_file_path]
|
|
258
|
-
else
|
|
259
|
-
pip_args = ["-i", pypi_url, "--trusted-host", URI.parse(pypi_url).host, package_file_path]
|
|
260
|
-
end
|
|
250
|
+
pip_args = PypiUrl.build_args(pypi_url) + [package_file_path]
|
|
261
251
|
spawn_env = {}
|
|
262
252
|
if plugin
|
|
263
253
|
venv_path = "#{PLUGIN_VENVS_DIR}/#{plugin}/.venv"
|
|
@@ -150,11 +150,14 @@ module OpenC3
|
|
|
150
150
|
if trigger['name'].nil? || trigger['group'].nil?
|
|
151
151
|
raise ReactionInputError.new "invalid trigger, must contain 'name' and 'group' keys: #{trigger}"
|
|
152
152
|
end
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
# Trigger names are only unique within a group so both parts identify
|
|
154
|
+
# the trigger. Keying on name alone rejects a reaction which references
|
|
155
|
+
# the same trigger name in two different groups.
|
|
156
|
+
trigger_key = "#{trigger['group']}__#{trigger['name']}"
|
|
157
|
+
unless trigger_hash[trigger_key].nil?
|
|
155
158
|
raise ReactionInputError.new "no duplicate triggers allowed: #{triggers}"
|
|
156
159
|
else
|
|
157
|
-
trigger_hash[
|
|
160
|
+
trigger_hash[trigger_key] = 1
|
|
158
161
|
end
|
|
159
162
|
end
|
|
160
163
|
return triggers
|
|
@@ -182,9 +182,11 @@ module OpenC3
|
|
|
182
182
|
# Seed settings from OPENC3_SETTING_<NAME> environment variables. Called by
|
|
183
183
|
# `openc3cli initsettings` during init container startup.
|
|
184
184
|
#
|
|
185
|
-
# By default a setting is
|
|
186
|
-
# value
|
|
187
|
-
#
|
|
185
|
+
# By default a setting is written when it does not exist yet, or when it
|
|
186
|
+
# still holds the value last seeded (see seeded_value?), so a changed env
|
|
187
|
+
# var takes effect but a value changed in the Admin Console survives a
|
|
188
|
+
# container restart. Set OPENC3_SETTINGS_OVERWRITE to write on every run
|
|
189
|
+
# instead.
|
|
188
190
|
#
|
|
189
191
|
# Nothing here aborts init by default. A bad setting name, a bad value, or a
|
|
190
192
|
# malformed control variable is reported on stdout and that one setting is
|
|
@@ -109,10 +109,14 @@ module OpenC3
|
|
|
109
109
|
modified_targets = []
|
|
110
110
|
if ENV['OPENC3_LOCAL_MODE']
|
|
111
111
|
modified_targets += OpenC3::LocalMode.modified_targets(scope: scope)
|
|
112
|
+
elsif !OpenC3::LocalMode.local_only_targets.empty?
|
|
113
|
+
modified_targets += OpenC3::LocalMode.modified_targets(scope: scope).select { |name| OpenC3::LocalMode.local_only_target?(name) }
|
|
112
114
|
end
|
|
113
115
|
# Always list the bucket, even in local mode, since the local directory
|
|
114
|
-
# only holds what COSMOS itself wrote there
|
|
115
|
-
|
|
116
|
+
# only holds what COSMOS itself wrote there. Local only targets are
|
|
117
|
+
# never read from the bucket.
|
|
118
|
+
bucket_targets = Bucket.getClient().list_files(bucket: ENV['OPENC3_CONFIG_BUCKET'], path: "#{scope}/targets_modified/", only_directories: true)
|
|
119
|
+
modified_targets += bucket_targets.reject { |name| OpenC3::LocalMode.local_only_target?(name) }
|
|
116
120
|
modified_targets.each do |target_name|
|
|
117
121
|
# A target could have been deleted without removing the modified files
|
|
118
122
|
# Thus we have to check for the existence of the target_name key
|
|
@@ -186,13 +190,15 @@ module OpenC3
|
|
|
186
190
|
def self.modified_files(target_name, scope:)
|
|
187
191
|
modified = []
|
|
188
192
|
|
|
189
|
-
if ENV['OPENC3_LOCAL_MODE']
|
|
193
|
+
if ENV['OPENC3_LOCAL_MODE'] or OpenC3::LocalMode.local_only_target?(target_name)
|
|
190
194
|
# LocalMode reports target relative paths, i.e. "procedures/new.rb",
|
|
191
195
|
# so add the target name to match the bucket listing below
|
|
192
196
|
modified += OpenC3::LocalMode.modified_files(target_name, scope: scope).map do |file_path|
|
|
193
197
|
"#{target_name}/#{file_path}"
|
|
194
198
|
end
|
|
195
199
|
end
|
|
200
|
+
# Local only targets are never read from the bucket
|
|
201
|
+
return modified.uniq.sort if OpenC3::LocalMode.local_only_target?(target_name)
|
|
196
202
|
# Always list the bucket, even in local mode. The local directory only
|
|
197
203
|
# holds what COSMOS itself wrote there, so a file placed directly in the
|
|
198
204
|
# bucket is modified but has no local copy.
|
|
@@ -216,16 +222,28 @@ module OpenC3
|
|
|
216
222
|
# some modified files while keeping others); when nil, every modified file
|
|
217
223
|
# for the target is removed (the original behavior).
|
|
218
224
|
def self.delete_modified(target_name, scope:, files: nil)
|
|
225
|
+
# Validate target_name to not allow directory traversal
|
|
226
|
+
unless OpenC3::LocalMode.safe_key?(target_name) and !target_name.include?('/')
|
|
227
|
+
raise ArgumentError, "Invalid target_name: #{target_name.inspect}"
|
|
228
|
+
end
|
|
219
229
|
if files && !files.empty?
|
|
230
|
+
# Only delete names that are actually modified files of this target.
|
|
231
|
+
# Anything else (another target, another scope via '..') is ignored.
|
|
232
|
+
allowed = modified_files(target_name, scope: scope)
|
|
220
233
|
files.each do |name|
|
|
234
|
+
next unless String === name
|
|
235
|
+
name = OpenC3::TargetFile.strip_modified(name)
|
|
236
|
+
next unless allowed.include?(name)
|
|
221
237
|
# TargetFile.destroy handles both local mode and the bucket.
|
|
222
238
|
OpenC3::TargetFile.destroy(scope, name)
|
|
223
239
|
end
|
|
224
240
|
return
|
|
225
241
|
end
|
|
226
|
-
if ENV['OPENC3_LOCAL_MODE']
|
|
242
|
+
if ENV['OPENC3_LOCAL_MODE'] or OpenC3::LocalMode.local_only_target?(target_name)
|
|
227
243
|
OpenC3::LocalMode.delete_modified(target_name, scope: scope)
|
|
228
244
|
end
|
|
245
|
+
# Local only targets are never written to the bucket
|
|
246
|
+
return if OpenC3::LocalMode.local_only_target?(target_name)
|
|
229
247
|
bucket = Bucket.getClient()
|
|
230
248
|
# Delete the remote files as well
|
|
231
249
|
resp = bucket.list_objects(
|
|
@@ -249,7 +267,7 @@ module OpenC3
|
|
|
249
267
|
Zip.continue_on_exists_proc = true
|
|
250
268
|
zip = Zip::File.open(zip_filename, create: true)
|
|
251
269
|
|
|
252
|
-
if ENV['OPENC3_LOCAL_MODE']
|
|
270
|
+
if ENV['OPENC3_LOCAL_MODE'] or OpenC3::LocalMode.local_only_target?(target_name)
|
|
253
271
|
OpenC3::LocalMode.zip_target(target_name, zip, scope: scope)
|
|
254
272
|
else
|
|
255
273
|
bucket = Bucket.getClient()
|
|
@@ -332,6 +350,35 @@ module OpenC3
|
|
|
332
350
|
result
|
|
333
351
|
end
|
|
334
352
|
|
|
353
|
+
# Returns just the packet names without fetching and parsing every packet
|
|
354
|
+
# definition, which is expensive for targets with thousands of packets.
|
|
355
|
+
# @return [Array<String>] Sorted packet names under the target_name
|
|
356
|
+
def self.packet_names(target_name, type: :TLM, scope:)
|
|
357
|
+
raise "Unknown type #{type} for #{target_name}" unless VALID_TYPES.include?(type)
|
|
358
|
+
raise "Target '#{target_name}' does not exist for scope: #{scope} (TargetModel)" unless get(name: target_name, scope: scope)
|
|
359
|
+
|
|
360
|
+
store_for_target(target_name, scope: scope).hkeys("#{scope}__openc3#{type.to_s.downcase}__#{target_name}").sort
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Returns the names of the hidden packets without parsing every packet definition.
|
|
364
|
+
# The list is stored when the target is installed. Targets installed before the
|
|
365
|
+
# list existed have it built once from the packet definitions and stored.
|
|
366
|
+
# @return [Array<String>] Sorted hidden packet names under the target_name
|
|
367
|
+
def self.hidden_packet_names(target_name, type: :TLM, scope:)
|
|
368
|
+
raise "Unknown type #{type} for #{target_name}" unless VALID_TYPES.include?(type)
|
|
369
|
+
|
|
370
|
+
store = store_for_target(target_name, scope: scope)
|
|
371
|
+
# Stored as a JSON string rather than a set so an empty list is distinct from a missing one
|
|
372
|
+
# e.g. DEFAULT__openc3tlm__INST__hidden
|
|
373
|
+
key = "#{scope}__openc3#{type.to_s.downcase}__#{target_name}__hidden"
|
|
374
|
+
names_json = store.get(key)
|
|
375
|
+
return JSON.parse(names_json) if names_json
|
|
376
|
+
|
|
377
|
+
names = packets(target_name, type: type, scope: scope).select { |packet| packet['hidden'] }.map { |packet| packet['packet_name'] }
|
|
378
|
+
store.set(key, JSON.generate(names))
|
|
379
|
+
names
|
|
380
|
+
end
|
|
381
|
+
|
|
335
382
|
# @return [Array<Hash>] All packet hashes under the target_name
|
|
336
383
|
def self.all_packet_name_descriptions(target_name, type: :TLM, scope:)
|
|
337
384
|
self.packets(target_name, type: type, scope: scope).map! { |hash| hash.slice("packet_name", "description") }
|
|
@@ -751,11 +798,12 @@ module OpenC3
|
|
|
751
798
|
# Version History is an Enterprise feature. The plugin deploy runs in the
|
|
752
799
|
# cmd-tlm-api process, which (unlike script-runner-api) doesn't otherwise
|
|
753
800
|
# require the store, so lazily load it on demand. Returns false in Core
|
|
754
|
-
# builds where the Enterprise gem isn't present
|
|
801
|
+
# builds where the Enterprise gem isn't present, and when the store is
|
|
802
|
+
# disabled (OPENC3_VERSION_HISTORY_DIR unset): its commits no-op there, so
|
|
803
|
+
# versioning would drop the modified copy without preserving it.
|
|
755
804
|
def version_store_available?
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
defined?(::VersionStore) ? true : false
|
|
805
|
+
require 'openc3-enterprise/utilities/version_store' unless defined?(::VersionStore)
|
|
806
|
+
defined?(::VersionStore) && ::VersionStore.enabled? ? true : false
|
|
759
807
|
rescue LoadError
|
|
760
808
|
false
|
|
761
809
|
end
|
|
@@ -765,6 +813,8 @@ module OpenC3
|
|
|
765
813
|
# copy), commits it, removes it, then commits the incoming plugin content.
|
|
766
814
|
# Best-effort: a versioning failure must not abort the plugin upgrade.
|
|
767
815
|
def apply_upgrade_version(name, new_data, ctx)
|
|
816
|
+
# Local only targets have no modified copy in the bucket
|
|
817
|
+
return if OpenC3::LocalMode.local_only_name?(name)
|
|
768
818
|
modified_key = "#{@scope}/targets_modified/#{name}"
|
|
769
819
|
resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: modified_key)
|
|
770
820
|
return unless resp && resp.body
|
|
@@ -791,6 +841,7 @@ module OpenC3
|
|
|
791
841
|
# a modified copy exists and differs (by bytes) from the rendered plugin
|
|
792
842
|
# content. Read-only.
|
|
793
843
|
def collect_modified_diff(name, new_data, collector)
|
|
844
|
+
return if OpenC3::LocalMode.local_only_name?(name)
|
|
794
845
|
resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: "#{@scope}/targets_modified/#{name}")
|
|
795
846
|
return unless resp && resp.body
|
|
796
847
|
collector << name if resp.body.read.b != new_data.b
|
|
@@ -843,6 +894,8 @@ module OpenC3
|
|
|
843
894
|
db_shard_store = Store.instance(db_shard: @db_shard)
|
|
844
895
|
db_shard_store.del("#{@scope}__openc3tlm__#{@name}")
|
|
845
896
|
db_shard_store.del("#{@scope}__openc3cmd__#{@name}")
|
|
897
|
+
db_shard_store.del("#{@scope}__openc3tlm__#{@name}__hidden")
|
|
898
|
+
db_shard_store.del("#{@scope}__openc3cmd__#{@name}__hidden")
|
|
846
899
|
db_shard_store.del("#{@scope}__TELEMETRYCNTS__{#{@name}}")
|
|
847
900
|
db_shard_store.del("#{@scope}__COMMANDCNTS__{#{@name}}")
|
|
848
901
|
|
|
@@ -980,6 +1033,7 @@ module OpenC3
|
|
|
980
1033
|
end
|
|
981
1034
|
CvtModel.set(json_hash, target_name: packet.target_name, packet_name: packet.packet_name, scope: @scope)
|
|
982
1035
|
end
|
|
1036
|
+
update_store_hidden_packet_names(db_shard_store, target_name, packets, type: :TLM, clear_old: clear_old)
|
|
983
1037
|
end
|
|
984
1038
|
end
|
|
985
1039
|
|
|
@@ -999,7 +1053,27 @@ module OpenC3
|
|
|
999
1053
|
raise e
|
|
1000
1054
|
end
|
|
1001
1055
|
end
|
|
1056
|
+
update_store_hidden_packet_names(db_shard_store, target_name, packets, type: :CMD, clear_old: clear_old)
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# Store the names of the hidden packets so listing packet names doesn't require
|
|
1061
|
+
# parsing every packet definition. When only some packets are updated
|
|
1062
|
+
# (clear_old false) merge them into the existing list.
|
|
1063
|
+
def update_store_hidden_packet_names(db_shard_store, target_name, packets, type:, clear_old:)
|
|
1064
|
+
# e.g. DEFAULT__openc3tlm__INST__hidden
|
|
1065
|
+
key = "#{@scope}__openc3#{type.to_s.downcase}__#{target_name}__hidden"
|
|
1066
|
+
names = []
|
|
1067
|
+
unless clear_old
|
|
1068
|
+
names_json = db_shard_store.get(key)
|
|
1069
|
+
# Without an existing list hidden_packet_names builds it from all the packets
|
|
1070
|
+
return unless names_json
|
|
1071
|
+
names = JSON.parse(names_json) - packets.keys
|
|
1072
|
+
end
|
|
1073
|
+
packets.each do |packet_name, packet|
|
|
1074
|
+
names << packet_name if packet.hidden
|
|
1002
1075
|
end
|
|
1076
|
+
db_shard_store.set(key, JSON.generate(names.sort))
|
|
1003
1077
|
end
|
|
1004
1078
|
|
|
1005
1079
|
def update_store_limits_groups(system)
|
|
@@ -72,6 +72,11 @@ module OpenC3
|
|
|
72
72
|
env['PYTHONPATH'] = nil
|
|
73
73
|
end
|
|
74
74
|
env['OPENC3_MICROSERVICE_NAME'] = microservice_name
|
|
75
|
+
# Tell the microservice not to report its own cpu/memory - we do that for
|
|
76
|
+
# it in publish_process_metrics, which also covers microservices that
|
|
77
|
+
# exec() into something that never loads the OpenC3 libraries. Only claim
|
|
78
|
+
# this where we can actually deliver on it.
|
|
79
|
+
env[Metric::SUPERVISED_ENV_VAR] = '1' if ProcessStats.supported?
|
|
75
80
|
container = microservice_config["container"]
|
|
76
81
|
scope = microservice_name.split("__")[0]
|
|
77
82
|
|
|
@@ -19,6 +19,8 @@ require 'childprocess'
|
|
|
19
19
|
require 'openc3'
|
|
20
20
|
require 'fileutils'
|
|
21
21
|
require 'tempfile'
|
|
22
|
+
require 'openc3/models/metric_model'
|
|
23
|
+
require 'openc3/utilities/process_stats'
|
|
22
24
|
|
|
23
25
|
module OpenC3
|
|
24
26
|
# Class to prevent an infinitely growing log file
|
|
@@ -156,6 +158,15 @@ module OpenC3
|
|
|
156
158
|
end
|
|
157
159
|
end
|
|
158
160
|
|
|
161
|
+
# The pid of the spawned process, or nil if it isn't running. Note this is
|
|
162
|
+
# the pid of plugin_microservice.rb, which exec()s the configured cmd, so it
|
|
163
|
+
# stays the pid of whatever the microservice actually ends up running.
|
|
164
|
+
def pid
|
|
165
|
+
@process ? @process.pid : nil
|
|
166
|
+
rescue Exception
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
159
170
|
def soft_stop
|
|
160
171
|
Thread.new do
|
|
161
172
|
Logger.info("Soft shutting down process: #{cmd_line()}", scope: @scope)
|
|
@@ -255,6 +266,9 @@ module OpenC3
|
|
|
255
266
|
@new_processes = {}
|
|
256
267
|
@changed_processes = {}
|
|
257
268
|
@removed_processes = {}
|
|
269
|
+
# microservice name => ProcessStats, so cpu deltas survive across cycles
|
|
270
|
+
@process_stats = {}
|
|
271
|
+
@process_stats_db_shards = {}
|
|
258
272
|
@mutex = Mutex.new
|
|
259
273
|
@shutdown = false
|
|
260
274
|
@shutdown_complete = false
|
|
@@ -264,6 +278,60 @@ module OpenC3
|
|
|
264
278
|
raise "Implement in subclass"
|
|
265
279
|
end
|
|
266
280
|
|
|
281
|
+
# Report cpu and memory for every microservice this operator is running.
|
|
282
|
+
#
|
|
283
|
+
# Microservices that are themselves OpenC3::Microservices could report this
|
|
284
|
+
# about themselves, but ones that exec() into a Rails app, a Python script
|
|
285
|
+
# or any other binary never load the OpenC3 libraries and so can't. Doing it
|
|
286
|
+
# here covers every microservice the same way, including plugins we don't
|
|
287
|
+
# ship, without asking anything of the plugin author.
|
|
288
|
+
def publish_process_metrics
|
|
289
|
+
return unless ProcessStats.supported?
|
|
290
|
+
|
|
291
|
+
@mutex.synchronize do
|
|
292
|
+
@processes.each do |name, p|
|
|
293
|
+
break if @shutdown
|
|
294
|
+
# Not started yet - queued behind the per cycle start limit
|
|
295
|
+
next if @new_processes[name]
|
|
296
|
+
|
|
297
|
+
# Only processes this operator spawned locally have a pid we can read
|
|
298
|
+
# from /proc. The KubernetesOperator's processes are pods elsewhere in
|
|
299
|
+
# the cluster, so they don't implement pid and keep reporting their
|
|
300
|
+
# own metrics from inside the pod.
|
|
301
|
+
next unless p.respond_to?(:pid)
|
|
302
|
+
pid = p.pid
|
|
303
|
+
next unless pid
|
|
304
|
+
|
|
305
|
+
# Isolate each process so one failure doesn't starve the rest of metrics
|
|
306
|
+
begin
|
|
307
|
+
stats = @process_stats[name]
|
|
308
|
+
# A respawn gets a new pid, and the old cpu totals don't carry over
|
|
309
|
+
if stats.nil? or stats.pid != pid
|
|
310
|
+
# Look up the shard before caching stats so a failed lookup is retried next cycle
|
|
311
|
+
@process_stats_db_shards[name] = MetricModel._lookup_db_shard(name, scope: p.scope)
|
|
312
|
+
stats = ProcessStats.new(pid)
|
|
313
|
+
@process_stats[name] = stats
|
|
314
|
+
end
|
|
315
|
+
next unless stats.sample
|
|
316
|
+
|
|
317
|
+
MetricModel.set_process(
|
|
318
|
+
{ 'name' => name, 'db_shard' => @process_stats_db_shards[name], 'values' => stats.values },
|
|
319
|
+
scope: p.scope
|
|
320
|
+
)
|
|
321
|
+
rescue => e
|
|
322
|
+
Logger.error("#{self.class} failed to publish process metrics for #{name}: #{e.class} #{e.message}")
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Stop tracking microservices that have gone away. Their metrics rows
|
|
327
|
+
# expire on their own (METRIC_EXPIRE_SECONDS).
|
|
328
|
+
(@process_stats.keys - @processes.keys).each do |name|
|
|
329
|
+
@process_stats.delete(name)
|
|
330
|
+
@process_stats_db_shards.delete(name)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
267
335
|
def start_new
|
|
268
336
|
@mutex.synchronize do
|
|
269
337
|
if @new_processes.length > 0
|
|
@@ -393,6 +461,7 @@ module OpenC3
|
|
|
393
461
|
respawn_changed()
|
|
394
462
|
start_new()
|
|
395
463
|
respawn_dead()
|
|
464
|
+
publish_process_metrics()
|
|
396
465
|
rescue => e
|
|
397
466
|
Logger.error("#{self.class} cycle error, continuing: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
|
398
467
|
end
|
|
@@ -879,7 +879,7 @@ module OpenC3
|
|
|
879
879
|
result = nil
|
|
880
880
|
synchronize_allow_reads(true) do
|
|
881
881
|
result = read_all(value_type, buffer, false).map! do |array|
|
|
882
|
-
array << @items[array[0]].
|
|
882
|
+
array << @items[array[0]].limits_state
|
|
883
883
|
end
|
|
884
884
|
end
|
|
885
885
|
return result
|
|
@@ -996,7 +996,7 @@ module OpenC3
|
|
|
996
996
|
# This is an optimization so we don't have to iterate through all the items when
|
|
997
997
|
# checking for limits.
|
|
998
998
|
def update_limits_items_cache(item)
|
|
999
|
-
if item.
|
|
999
|
+
if item.limits_values || item.state_colors
|
|
1000
1000
|
@limits_items ||= []
|
|
1001
1001
|
@limits_items_hash ||= {}
|
|
1002
1002
|
unless @limits_items_hash[item]
|
|
@@ -1306,7 +1306,7 @@ module OpenC3
|
|
|
1306
1306
|
json_hash["#{item.name}__F"] = read_item(item, :FORMATTED, @buffer, given_raw)
|
|
1307
1307
|
end
|
|
1308
1308
|
if include_limits_states
|
|
1309
|
-
limits_state = item.
|
|
1309
|
+
limits_state = item.limits_state
|
|
1310
1310
|
end
|
|
1311
1311
|
if limits_state
|
|
1312
1312
|
json_hash["#{item.name}__L"] = limits_state
|
|
@@ -1339,6 +1339,12 @@ module OpenC3
|
|
|
1339
1339
|
return unless @buffer
|
|
1340
1340
|
return unless @obfuscated_items
|
|
1341
1341
|
|
|
1342
|
+
# Given values hold the plaintext the user sent, so drop them for obfuscated items
|
|
1343
|
+
if @given_values
|
|
1344
|
+
obfuscated_names = @obfuscated_items.map { |item| item.name.upcase }
|
|
1345
|
+
@given_values.delete_if { |key, _| obfuscated_names.include?(key.to_s.upcase) }
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1342
1348
|
@obfuscated_items.each do |item|
|
|
1343
1349
|
next if item.data_type == :DERIVED
|
|
1344
1350
|
|
|
@@ -1441,7 +1447,7 @@ module OpenC3
|
|
|
1441
1447
|
|
|
1442
1448
|
def handle_limits_states(item, value)
|
|
1443
1449
|
# Retrieve limits state for the given value
|
|
1444
|
-
limits_state = item.state_colors[value]
|
|
1450
|
+
limits_state = item.state_colors ? item.state_colors[value] : nil
|
|
1445
1451
|
|
|
1446
1452
|
if item.limits.state != limits_state # PacketItemLimits state has changed
|
|
1447
1453
|
# Save old limits state
|
|
@@ -99,7 +99,11 @@ module OpenC3
|
|
|
99
99
|
# Note: DERIVED is not a valid converted type. Also TIME is currently only a converted type
|
|
100
100
|
CONVERTED_DATA_TYPES = [:INT, :UINT, :FLOAT, :STRING, :BLOCK, :BOOL, :OBJECT, :ARRAY, :ANY, :TIME]
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions.
|
|
103
|
+
# Pass false in processes that never display them (e.g. decom and
|
|
104
|
+
# interface microservices) to reduce memory on large definitions.
|
|
105
|
+
def initialize(descriptions: true)
|
|
106
|
+
@descriptions = descriptions
|
|
103
107
|
@name = nil
|
|
104
108
|
@telemetry = {}
|
|
105
109
|
@commands = {}
|
|
@@ -145,7 +149,12 @@ module OpenC3
|
|
|
145
149
|
# Handle .xtce files
|
|
146
150
|
extension = File.extname(filename).to_s.downcase
|
|
147
151
|
if extension == ".xtce" or extension == ".xml"
|
|
148
|
-
XtceParser.process(@commands, @telemetry, @warnings, filename, process_target_name)
|
|
152
|
+
target_name = XtceParser.process(@commands, @telemetry, @warnings, filename, process_target_name)
|
|
153
|
+
# XtceParser adds packets straight into @commands / @telemetry without
|
|
154
|
+
# going through finish_packet, so the ID lookup metadata finish_packet
|
|
155
|
+
# builds has to be built here instead. Without it Telemetry#identify
|
|
156
|
+
# and Commands#identify find no hash for the target and raise on nil.
|
|
157
|
+
build_xtce_id_metadata(target_name)
|
|
149
158
|
return
|
|
150
159
|
end
|
|
151
160
|
|
|
@@ -340,6 +349,12 @@ module OpenC3
|
|
|
340
349
|
def finish_packet
|
|
341
350
|
finish_item()
|
|
342
351
|
if @current_packet
|
|
352
|
+
unless @descriptions
|
|
353
|
+
@current_packet.description = nil
|
|
354
|
+
# Reserved items (PACKET_TIMESECONDS, etc.) get descriptions from
|
|
355
|
+
# define_reserved_items and never pass through finish_item
|
|
356
|
+
@current_packet.sorted_items.each { |item| item.description = nil }
|
|
357
|
+
end
|
|
343
358
|
@warnings += @current_packet.check_bit_offsets
|
|
344
359
|
if !@current_packet.virtual && !@current_packet.disabled && !@current_packet.catchall && @current_packet.target_name != 'UNKNOWN' && @current_packet.id_items.empty?
|
|
345
360
|
type = @current_cmd_or_tlm == COMMAND ? "Command" : "Telemetry"
|
|
@@ -430,6 +445,36 @@ module OpenC3
|
|
|
430
445
|
|
|
431
446
|
protected
|
|
432
447
|
|
|
448
|
+
# Build the ID lookup metadata for every packet an XTCE file just added to
|
|
449
|
+
# the given target. Packets are walked in the order XtceParser left them in,
|
|
450
|
+
# which is the order the rest of COSMOS sees them, so duplicate ID values
|
|
451
|
+
# resolve the same way here as they do everywhere else.
|
|
452
|
+
#
|
|
453
|
+
# @param target_name [String] Target the XTCE file defined, or nil if the
|
|
454
|
+
# file defined no target
|
|
455
|
+
def build_xtce_id_metadata(target_name)
|
|
456
|
+
return unless target_name
|
|
457
|
+
|
|
458
|
+
@commands[target_name]&.each_value do |packet|
|
|
459
|
+
next if packet.virtual
|
|
460
|
+
|
|
461
|
+
if packet.subpacket
|
|
462
|
+
build_id_metadata(packet, @cmd_subpacket_id_value_hash, @cmd_subpacket_id_signature, @cmd_subpacket_unique_id_mode)
|
|
463
|
+
else
|
|
464
|
+
build_id_metadata(packet, @cmd_id_value_hash, @cmd_id_signature, @cmd_unique_id_mode)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
@telemetry[target_name]&.each_value do |packet|
|
|
468
|
+
next if packet.virtual
|
|
469
|
+
|
|
470
|
+
if packet.subpacket
|
|
471
|
+
build_id_metadata(packet, @tlm_subpacket_id_value_hash, @tlm_subpacket_id_signature, @tlm_subpacket_unique_id_mode)
|
|
472
|
+
else
|
|
473
|
+
build_id_metadata(packet, @tlm_id_value_hash, @tlm_id_signature, @tlm_unique_id_mode)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
433
478
|
def build_id_metadata(packet, id_value_hash, id_signature_hash, unique_id_mode_hash)
|
|
434
479
|
target_id_value_hash = id_value_hash[packet.target_name]
|
|
435
480
|
target_id_value_hash = {} unless target_id_value_hash
|
|
@@ -891,6 +936,7 @@ module OpenC3
|
|
|
891
936
|
# Finish updating packet item
|
|
892
937
|
def finish_item
|
|
893
938
|
if @current_item
|
|
939
|
+
@current_item.description = nil unless @descriptions
|
|
894
940
|
@current_packet.set_item(@current_item)
|
|
895
941
|
if @current_cmd_or_tlm == TELEMETRY
|
|
896
942
|
target_latest_data = @latest_data[@current_packet.target_name]
|