openc3 7.4.1 → 7.5.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/pipinstall +18 -5
  3. data/bin/pipuninstall +1 -1
  4. data/bin/uvinstall +36 -8
  5. data/data/config/default_plugins.yaml +43 -0
  6. data/data/config/interface_modifiers.yaml +22 -7
  7. data/data/config/item_modifiers.yaml +14 -4
  8. data/data/config/plugins.yaml +1 -1
  9. data/data/config/widgets.yaml +1 -1
  10. data/lib/openc3/api/cmd_api.rb +10 -13
  11. data/lib/openc3/api/interface_api.rb +1 -2
  12. data/lib/openc3/api/limits_api.rb +149 -160
  13. data/lib/openc3/api/metrics_api.rb +26 -1
  14. data/lib/openc3/api/router_api.rb +1 -2
  15. data/lib/openc3/api/tlm_api.rb +225 -71
  16. data/lib/openc3/config/config_parser.rb +3 -1
  17. data/lib/openc3/core_ext/tempfile.rb +2 -0
  18. data/lib/openc3/interfaces/file_interface.rb +6 -1
  19. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  20. data/lib/openc3/interfaces/interface.rb +27 -3
  21. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  22. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  23. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  24. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  25. data/lib/openc3/interfaces.rb +1 -0
  26. data/lib/openc3/logs/log_writer.rb +14 -4
  27. data/lib/openc3/microservices/decom_microservice.rb +30 -5
  28. data/lib/openc3/microservices/interface_decom_common.rb +3 -1
  29. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  30. data/lib/openc3/microservices/log_microservice.rb +1 -3
  31. data/lib/openc3/microservices/microservice.rb +25 -1
  32. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  33. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  34. data/lib/openc3/models/cvt_model.rb +7 -2
  35. data/lib/openc3/models/interface_model.rb +1 -1
  36. data/lib/openc3/models/interface_status_model.rb +6 -0
  37. data/lib/openc3/models/metric_model.rb +52 -4
  38. data/lib/openc3/models/microservice_model.rb +7 -3
  39. data/lib/openc3/models/plugin_model.rb +12 -12
  40. data/lib/openc3/models/python_package_model.rb +3 -13
  41. data/lib/openc3/models/target_model.rb +92 -15
  42. data/lib/openc3/operators/microservice_operator.rb +5 -0
  43. data/lib/openc3/operators/operator.rb +69 -0
  44. data/lib/openc3/packets/packet.rb +10 -4
  45. data/lib/openc3/packets/packet_config.rb +12 -1
  46. data/lib/openc3/packets/packet_item.rb +40 -22
  47. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  48. data/lib/openc3/packets/structure_item.rb +3 -2
  49. data/lib/openc3/script/api_shared.rb +62 -86
  50. data/lib/openc3/script/extract.rb +263 -20
  51. data/lib/openc3/script/plugins.rb +4 -11
  52. data/lib/openc3/script/script.rb +1 -1
  53. data/lib/openc3/script/storage.rb +81 -10
  54. data/lib/openc3/system/system.rb +9 -6
  55. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  56. data/lib/openc3/topics/command_topic.rb +2 -1
  57. data/lib/openc3/topics/limits_event_topic.rb +90 -3
  58. data/lib/openc3/topics/router_topic.rb +7 -2
  59. data/lib/openc3/topics/system_events_topic.rb +9 -6
  60. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  62. data/lib/openc3/topics/topic.rb +162 -3
  63. data/lib/openc3/utilities/bucket.rb +5 -0
  64. data/lib/openc3/utilities/cli_generator.rb +67 -15
  65. data/lib/openc3/utilities/config_overlay.rb +35 -17
  66. data/lib/openc3/utilities/default_plugins.rb +33 -0
  67. data/lib/openc3/utilities/local_mode.rb +82 -46
  68. data/lib/openc3/utilities/metric.rb +16 -0
  69. data/lib/openc3/utilities/process_stats.rb +160 -0
  70. data/lib/openc3/utilities/pypi_url.rb +65 -0
  71. data/lib/openc3/utilities/questdb_client.rb +140 -32
  72. data/lib/openc3/utilities/running_script.rb +22 -18
  73. data/lib/openc3/utilities/secrets.rb +5 -0
  74. data/lib/openc3/utilities/store_autoload.rb +23 -4
  75. data/lib/openc3/utilities/store_queued.rb +37 -9
  76. data/lib/openc3/utilities/target_file.rb +57 -12
  77. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  78. data/lib/openc3/version.rb +5 -5
  79. data/templates/conversion/conversion.py +6 -2
  80. data/templates/conversion/conversion.rb +5 -0
  81. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  82. data/templates/plugin/.github/workflows/release.yml +45 -0
  83. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  84. data/templates/plugin/.gitignore +6 -0
  85. data/templates/plugin/README.md +114 -2
  86. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  87. data/templates/plugin/plugin.gemspec +10 -1
  88. data/templates/plugin/pyproject.toml +119 -0
  89. data/templates/tool_angular/package.json +2 -2
  90. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  91. data/templates/tool_react/package.json +1 -1
  92. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  93. data/templates/tool_svelte/package.json +2 -1
  94. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  95. data/templates/tool_vue/package.json +3 -3
  96. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  97. data/templates/widget/package.json +2 -2
  98. data/templates/widget/pnpm-workspace.yaml +16 -0
  99. metadata +16 -1
@@ -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"
@@ -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") }
@@ -411,10 +458,13 @@ module OpenC3
411
458
  end
412
459
 
413
460
  def self.get_item_to_packet_map(target_name, scope:)
414
- cache_time, item_map = @@item_map_cache[target_name]
415
- return item_map if item_map and (Time.now - cache_time) < ITEM_MAP_CACHE_TIMEOUT
416
- item_map_key = "#{scope}__#{target_name}__item_to_packet_map"
461
+ # Normalize before building the key so lowercase lookups share the entry
462
+ # that update_store_item_map and undeploy refresh and invalidate
417
463
  target_name = target_name.upcase
464
+ # The cache is keyed by item_map_key since the same target can exist in multiple scopes
465
+ item_map_key = "#{scope}__#{target_name}__item_to_packet_map"
466
+ cache_time, item_map = @@item_map_cache[item_map_key]
467
+ return item_map if item_map and (Time.now - cache_time) < ITEM_MAP_CACHE_TIMEOUT
418
468
  store = store_for_target(target_name, scope: scope)
419
469
  json_data = store.get(item_map_key)
420
470
  if json_data
@@ -423,7 +473,7 @@ module OpenC3
423
473
  item_map = build_item_to_packet_map(target_name, scope: scope)
424
474
  store.set(item_map_key, JSON.generate(item_map, allow_nan: true))
425
475
  end
426
- @@item_map_cache[target_name] = [Time.now, item_map]
476
+ @@item_map_cache[item_map_key] = [Time.now, item_map]
427
477
  return item_map
428
478
  end
429
479
 
@@ -751,11 +801,12 @@ module OpenC3
751
801
  # Version History is an Enterprise feature. The plugin deploy runs in the
752
802
  # cmd-tlm-api process, which (unlike script-runner-api) doesn't otherwise
753
803
  # require the store, so lazily load it on demand. Returns false in Core
754
- # builds where the Enterprise gem isn't present.
804
+ # builds where the Enterprise gem isn't present, and when the store is
805
+ # disabled (OPENC3_VERSION_HISTORY_DIR unset): its commits no-op there, so
806
+ # versioning would drop the modified copy without preserving it.
755
807
  def version_store_available?
756
- return true if defined?(::VersionStore)
757
- require 'openc3-enterprise/utilities/version_store'
758
- defined?(::VersionStore) ? true : false
808
+ require 'openc3-enterprise/utilities/version_store' unless defined?(::VersionStore)
809
+ defined?(::VersionStore) && ::VersionStore.enabled? ? true : false
759
810
  rescue LoadError
760
811
  false
761
812
  end
@@ -765,6 +816,8 @@ module OpenC3
765
816
  # copy), commits it, removes it, then commits the incoming plugin content.
766
817
  # Best-effort: a versioning failure must not abort the plugin upgrade.
767
818
  def apply_upgrade_version(name, new_data, ctx)
819
+ # Local only targets have no modified copy in the bucket
820
+ return if OpenC3::LocalMode.local_only_name?(name)
768
821
  modified_key = "#{@scope}/targets_modified/#{name}"
769
822
  resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: modified_key)
770
823
  return unless resp && resp.body
@@ -791,6 +844,7 @@ module OpenC3
791
844
  # a modified copy exists and differs (by bytes) from the rendered plugin
792
845
  # content. Read-only.
793
846
  def collect_modified_diff(name, new_data, collector)
847
+ return if OpenC3::LocalMode.local_only_name?(name)
794
848
  resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: "#{@scope}/targets_modified/#{name}")
795
849
  return unless resp && resp.body
796
850
  collector << name if resp.body.read.b != new_data.b
@@ -843,6 +897,8 @@ module OpenC3
843
897
  db_shard_store = Store.instance(db_shard: @db_shard)
844
898
  db_shard_store.del("#{@scope}__openc3tlm__#{@name}")
845
899
  db_shard_store.del("#{@scope}__openc3cmd__#{@name}")
900
+ db_shard_store.del("#{@scope}__openc3tlm__#{@name}__hidden")
901
+ db_shard_store.del("#{@scope}__openc3cmd__#{@name}__hidden")
846
902
  db_shard_store.del("#{@scope}__TELEMETRYCNTS__{#{@name}}")
847
903
  db_shard_store.del("#{@scope}__COMMANDCNTS__{#{@name}}")
848
904
 
@@ -864,7 +920,7 @@ module OpenC3
864
920
  # Delete item_map
865
921
  item_map_key = "#{@scope}__#{@name}__item_to_packet_map"
866
922
  db_shard_store.del(item_map_key)
867
- @@item_map_cache[@name] = nil
923
+ @@item_map_cache[item_map_key] = nil
868
924
 
869
925
  topic = { kind: 'deleted', type: 'target', name: @name }
870
926
  # The UNKNOWN target doesn't have an associated plugin
@@ -980,6 +1036,7 @@ module OpenC3
980
1036
  end
981
1037
  CvtModel.set(json_hash, target_name: packet.target_name, packet_name: packet.packet_name, scope: @scope)
982
1038
  end
1039
+ update_store_hidden_packet_names(db_shard_store, target_name, packets, type: :TLM, clear_old: clear_old)
983
1040
  end
984
1041
  end
985
1042
 
@@ -999,7 +1056,27 @@ module OpenC3
999
1056
  raise e
1000
1057
  end
1001
1058
  end
1059
+ update_store_hidden_packet_names(db_shard_store, target_name, packets, type: :CMD, clear_old: clear_old)
1060
+ end
1061
+ end
1062
+
1063
+ # Store the names of the hidden packets so listing packet names doesn't require
1064
+ # parsing every packet definition. When only some packets are updated
1065
+ # (clear_old false) merge them into the existing list.
1066
+ def update_store_hidden_packet_names(db_shard_store, target_name, packets, type:, clear_old:)
1067
+ # e.g. DEFAULT__openc3tlm__INST__hidden
1068
+ key = "#{@scope}__openc3#{type.to_s.downcase}__#{target_name}__hidden"
1069
+ names = []
1070
+ unless clear_old
1071
+ names_json = db_shard_store.get(key)
1072
+ # Without an existing list hidden_packet_names builds it from all the packets
1073
+ return unless names_json
1074
+ names = JSON.parse(names_json) - packets.keys
1075
+ end
1076
+ packets.each do |packet_name, packet|
1077
+ names << packet_name if packet.hidden
1002
1078
  end
1079
+ db_shard_store.set(key, JSON.generate(names.sort))
1003
1080
  end
1004
1081
 
1005
1082
  def update_store_limits_groups(system)
@@ -1027,7 +1104,7 @@ module OpenC3
1027
1104
  item_map_key = "#{@scope}__#{@name}__item_to_packet_map"
1028
1105
  item_map = self.class.build_item_to_packet_map(@name, scope: @scope)
1029
1106
  Store.instance(db_shard: @db_shard).set(item_map_key, JSON.generate(item_map, allow_nan: true))
1030
- @@item_map_cache[@name] = [Time.now, item_map]
1107
+ @@item_map_cache[item_map_key] = [Time.now, item_map]
1031
1108
  end
1032
1109
 
1033
1110
  def update_store(system, clear_old: true)
@@ -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 = {}
@@ -345,6 +349,12 @@ module OpenC3
345
349
  def finish_packet
346
350
  finish_item()
347
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
348
358
  @warnings += @current_packet.check_bit_offsets
349
359
  if !@current_packet.virtual && !@current_packet.disabled && !@current_packet.catchall && @current_packet.target_name != 'UNKNOWN' && @current_packet.id_items.empty?
350
360
  type = @current_cmd_or_tlm == COMMAND ? "Command" : "Telemetry"
@@ -926,6 +936,7 @@ module OpenC3
926
936
  # Finish updating packet item
927
937
  def finish_item
928
938
  if @current_item
939
+ @current_item.description = nil unless @descriptions
929
940
  @current_packet.set_item(@current_item)
930
941
  if @current_cmd_or_tlm == TELEMETRY
931
942
  target_latest_data = @latest_data[@current_packet.target_name]
@@ -85,8 +85,22 @@ module OpenC3
85
85
  # The allowable state colors
86
86
  STATE_COLORS = [:GREEN, :YELLOW, :RED]
87
87
 
88
+ # PacketItemLimits is created on first access since most items have no
89
+ # limits and large definitions have hundreds of thousands of items.
88
90
  # @return [PacketItemLimits] All information regarding limits for this PacketItem
89
- attr_reader :limits
91
+ def limits
92
+ @limits ||= PacketItemLimits.new
93
+ end
94
+
95
+ # @return [Symbol, nil] The limits state without creating PacketItemLimits
96
+ def limits_state
97
+ @limits ? @limits.state : nil
98
+ end
99
+
100
+ # @return [Hash, nil] The limits values without creating PacketItemLimits
101
+ def limits_values
102
+ @limits ? @limits.values : nil
103
+ end
90
104
 
91
105
  # @return [Boolean] Whether the parameter must be obfuscated from logs or not
92
106
  attr_accessor :obfuscate
@@ -110,7 +124,7 @@ module OpenC3
110
124
  @obfuscate = false
111
125
  @messages_disabled = nil
112
126
  @state_colors = nil
113
- @limits = PacketItemLimits.new
127
+ @limits = nil
114
128
  @meta = nil
115
129
  end
116
130
 
@@ -165,7 +179,6 @@ module OpenC3
165
179
  end
166
180
 
167
181
  @states = upcase_states
168
- @state_colors ||= {}
169
182
  else
170
183
  @states = nil
171
184
  end
@@ -317,7 +330,7 @@ module OpenC3
317
330
  item.hazardous = self.hazardous.clone if self.hazardous
318
331
  item.messages_disabled = self.messages_disabled.clone if self.messages_disabled
319
332
  item.state_colors = self.state_colors.clone if self.state_colors
320
- item.limits = self.limits.clone if self.limits
333
+ item.limits = @limits.clone if @limits
321
334
  item.meta = self.meta.clone if @meta
322
335
  item.obfuscate = self.obfuscate.clone if @obfuscate
323
336
  item
@@ -385,14 +398,19 @@ module OpenC3
385
398
  if @states
386
399
  @states.each do |state_name, state_value|
387
400
  config << " STATE #{state_name.to_s.quote_if_necessary} #{state_value.to_s.quote_if_necessary}"
388
- if @hazardous and @hazardous[state_name]
389
- config << " HAZARDOUS #{@hazardous[state_name].to_s.quote_if_necessary}"
390
- end
391
- if @messages_disabled and @messages_disabled[state_name]
392
- config << " DISABLE_MESSAGES"
393
- end
394
- if @state_colors and @state_colors[state_name]
395
- config << " #{@state_colors[state_name]}"
401
+ # Only write the attributes STATE accepts for this packet type so the config parses back
402
+ if cmd_or_tlm == :TELEMETRY
403
+ if @state_colors and @state_colors[state_name]
404
+ config << " #{@state_colors[state_name]}"
405
+ end
406
+ else
407
+ if @hazardous and @hazardous[state_name]
408
+ config << " HAZARDOUS"
409
+ config << " #{@hazardous[state_name].to_s.quote_if_necessary}" unless @hazardous[state_name].to_s.empty?
410
+ end
411
+ if @messages_disabled and @messages_disabled[state_name]
412
+ config << " DISABLE_MESSAGES"
413
+ end
396
414
  end
397
415
  config << "\n"
398
416
  end
@@ -401,16 +419,16 @@ module OpenC3
401
419
  config << self.read_conversion.to_config(:READ) if self.read_conversion
402
420
  config << self.write_conversion.to_config(:WRITE) if self.write_conversion
403
421
 
404
- if self.limits.values
405
- self.limits.values.each do |limits_set, limits_values|
406
- config << " LIMITS #{limits_set} #{self.limits.persistence_setting} #{self.limits.enabled ? 'ENABLED' : 'DISABLED'} #{limits_values[0]} #{limits_values[1]} #{limits_values[2]} #{limits_values[3]}"
422
+ if limits_values
423
+ @limits.values.each do |limits_set, limits_values|
424
+ config << " LIMITS #{limits_set} #{@limits.persistence_setting} #{@limits.enabled ? 'ENABLED' : 'DISABLED'} #{limits_values[0]} #{limits_values[1]} #{limits_values[2]} #{limits_values[3]}"
407
425
  if limits_values[4] && limits_values[5]
408
426
  config << " #{limits_values[4]} #{limits_values[5]}\n"
409
427
  else
410
428
  config << "\n"
411
429
  end
412
430
  end
413
- config << self.limits.response.to_config if self.limits.response
431
+ config << @limits.response.to_config if @limits.response
414
432
  end
415
433
 
416
434
  if @meta
@@ -456,19 +474,19 @@ module OpenC3
456
474
  config['write_conversion'] = self.write_conversion.as_json(*a) if self.write_conversion
457
475
 
458
476
  config['limits'] ||= {}
459
- if self.limits.enabled
477
+ if @limits && @limits.enabled
460
478
  config['limits']['enabled'] = true
461
- elsif self.limits.values || (self.state_colors && self.state_colors.length > 0)
479
+ elsif limits_values || (self.state_colors && self.state_colors.length > 0)
462
480
  # Only set to false if there are limits or state colors
463
481
  # to avoid items without limits acting like they can be enabled
464
482
  config['limits']['enabled'] = false
465
483
  end
466
- if self.limits.values
484
+ if limits_values
467
485
  # Only set these if there are limits.values because persistence_setting has a default
468
486
  # and we don't want keys on the 'limits' hash if there aren't any limits
469
- config['limits']['persistence_setting'] = self.limits.persistence_setting if self.limits.persistence_setting
470
- config['limits']['response'] = self.limits.response.to_s if self.limits.response
471
- self.limits.values.each do |limits_set, limits_values|
487
+ config['limits']['persistence_setting'] = @limits.persistence_setting if @limits.persistence_setting
488
+ config['limits']['response'] = @limits.response.to_s if @limits.response
489
+ @limits.values.each do |limits_set, limits_values|
472
490
  limits = {}
473
491
  limits['red_low'] = limits_values[0]
474
492
  limits['yellow_low'] = limits_values[1]