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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +4 -3
  3. data/bin/pipinstall +18 -5
  4. data/bin/pipuninstall +1 -1
  5. data/bin/uvinstall +36 -8
  6. data/data/config/default_plugins.yaml +43 -0
  7. data/data/config/interface_modifiers.yaml +22 -7
  8. data/data/config/item_modifiers.yaml +14 -4
  9. data/data/config/plugins.yaml +1 -1
  10. data/data/config/widgets.yaml +1 -1
  11. data/lib/openc3/api/cmd_api.rb +10 -13
  12. data/lib/openc3/api/interface_api.rb +1 -2
  13. data/lib/openc3/api/limits_api.rb +1 -1
  14. data/lib/openc3/api/metrics_api.rb +26 -1
  15. data/lib/openc3/api/router_api.rb +1 -2
  16. data/lib/openc3/api/tlm_api.rb +21 -21
  17. data/lib/openc3/config/config_parser.rb +3 -1
  18. data/lib/openc3/core_ext/tempfile.rb +2 -0
  19. data/lib/openc3/interfaces/file_interface.rb +6 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  21. data/lib/openc3/interfaces/interface.rb +27 -3
  22. data/lib/openc3/interfaces/protocols/protocol.rb +8 -0
  23. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  24. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  25. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  26. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  27. data/lib/openc3/interfaces.rb +1 -0
  28. data/lib/openc3/io/json_api_object.rb +24 -3
  29. data/lib/openc3/io/json_drb_object.rb +3 -2
  30. data/lib/openc3/logs/log_writer.rb +13 -4
  31. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  32. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  33. data/lib/openc3/microservices/log_microservice.rb +1 -3
  34. data/lib/openc3/microservices/microservice.rb +25 -1
  35. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  36. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  37. data/lib/openc3/models/interface_model.rb +1 -1
  38. data/lib/openc3/models/interface_status_model.rb +6 -0
  39. data/lib/openc3/models/metric_model.rb +52 -4
  40. data/lib/openc3/models/microservice_model.rb +7 -3
  41. data/lib/openc3/models/plugin_model.rb +12 -12
  42. data/lib/openc3/models/python_package_model.rb +3 -13
  43. data/lib/openc3/models/reaction_model.rb +6 -3
  44. data/lib/openc3/models/setting_model.rb +5 -3
  45. data/lib/openc3/models/target_model.rb +83 -9
  46. data/lib/openc3/operators/microservice_operator.rb +5 -0
  47. data/lib/openc3/operators/operator.rb +69 -0
  48. data/lib/openc3/packets/packet.rb +10 -4
  49. data/lib/openc3/packets/packet_config.rb +48 -2
  50. data/lib/openc3/packets/packet_item.rb +27 -14
  51. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  52. data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
  53. data/lib/openc3/packets/structure_item.rb +3 -2
  54. data/lib/openc3/script/api_shared.rb +58 -84
  55. data/lib/openc3/script/extract.rb +215 -19
  56. data/lib/openc3/script/plugins.rb +4 -11
  57. data/lib/openc3/script/storage.rb +81 -10
  58. data/lib/openc3/script/tables.rb +5 -1
  59. data/lib/openc3/system/system.rb +9 -6
  60. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/command_topic.rb +2 -1
  62. data/lib/openc3/topics/router_topic.rb +2 -1
  63. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  64. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  65. data/lib/openc3/topics/topic.rb +154 -2
  66. data/lib/openc3/utilities/cli_generator.rb +67 -15
  67. data/lib/openc3/utilities/config_overlay.rb +35 -17
  68. data/lib/openc3/utilities/default_plugins.rb +33 -0
  69. data/lib/openc3/utilities/local_mode.rb +82 -46
  70. data/lib/openc3/utilities/metric.rb +16 -0
  71. data/lib/openc3/utilities/process_stats.rb +160 -0
  72. data/lib/openc3/utilities/pypi_url.rb +65 -0
  73. data/lib/openc3/utilities/questdb_client.rb +43 -46
  74. data/lib/openc3/utilities/running_script.rb +22 -18
  75. data/lib/openc3/utilities/store_autoload.rb +20 -3
  76. data/lib/openc3/utilities/store_queued.rb +37 -9
  77. data/lib/openc3/utilities/target_file.rb +57 -12
  78. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  79. data/lib/openc3/version.rb +5 -5
  80. data/templates/conversion/conversion.py +6 -2
  81. data/templates/conversion/conversion.rb +5 -0
  82. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  83. data/templates/plugin/.github/workflows/release.yml +45 -0
  84. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  85. data/templates/plugin/.gitignore +6 -0
  86. data/templates/plugin/README.md +114 -2
  87. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  88. data/templates/plugin/plugin.gemspec +10 -1
  89. data/templates/plugin/pyproject.toml +119 -0
  90. data/templates/tool_angular/package.json +2 -2
  91. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  92. data/templates/tool_react/package.json +1 -1
  93. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  94. data/templates/tool_svelte/package.json +2 -1
  95. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  96. data/templates/tool_vue/package.json +3 -3
  97. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  98. data/templates/widget/package.json +2 -2
  99. data/templates/widget/pnpm-workspace.yaml +16 -0
  100. 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
- _db_sharded_get("#{scope}#{PRIMARY_KEY}", name: name, scope: scope)
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}#{PRIMARY_KEY}", name: name, scope: 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
- metric_model = MetricModel.new(name: @name, scope: @scope)
313
- metric_model.destroy
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
- unless validate_only
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 for pypi index and trusted-host arguments.
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
- args = ["-i", pypi_url]
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
- if ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
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
- trigger_name = trigger['name']
154
- unless trigger_hash[trigger_name].nil?
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[trigger_name] = 1
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 only written when it does not already exist, so a
186
- # value changed in the Admin Console survives a container restart. Set
187
- # OPENC3_SETTINGS_OVERWRITE to write on every run instead.
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
- modified_targets += Bucket.getClient().list_files(bucket: ENV['OPENC3_CONFIG_BUCKET'], path: "#{scope}/targets_modified/", only_directories: true)
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
- return true if defined?(::VersionStore)
757
- require 'openc3-enterprise/utilities/version_store'
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]].limits.state
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.limits.values || item.state_colors
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.limits.state
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
- def initialize
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]