openc3 7.0.0 → 7.1.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +105 -13
  3. data/bin/pipinstall +38 -6
  4. data/data/config/command_modifiers.yaml +1 -0
  5. data/data/config/item_modifiers.yaml +2 -1
  6. data/data/config/microservice.yaml +12 -1
  7. data/data/config/parameter_modifiers.yaml +49 -7
  8. data/data/config/table_parameter_modifiers.yaml +3 -1
  9. data/data/config/target.yaml +11 -0
  10. data/data/config/target_config.yaml +6 -2
  11. data/lib/openc3/accessors/template_accessor.rb +9 -0
  12. data/lib/openc3/api/cmd_api.rb +2 -1
  13. data/lib/openc3/api/metrics_api.rb +11 -1
  14. data/lib/openc3/api/tlm_api.rb +21 -6
  15. data/lib/openc3/core_ext/faraday.rb +1 -1
  16. data/lib/openc3/interfaces/interface.rb +1 -6
  17. data/lib/openc3/io/json_api.rb +1 -1
  18. data/lib/openc3/logs/log_writer.rb +3 -1
  19. data/lib/openc3/microservices/decom_common.rb +128 -0
  20. data/lib/openc3/microservices/decom_microservice.rb +27 -96
  21. data/lib/openc3/microservices/interface_decom_common.rb +28 -10
  22. data/lib/openc3/microservices/interface_microservice.rb +16 -9
  23. data/lib/openc3/microservices/log_microservice.rb +1 -1
  24. data/lib/openc3/microservices/microservice.rb +3 -2
  25. data/lib/openc3/microservices/queue_microservice.rb +1 -1
  26. data/lib/openc3/microservices/scope_cleanup_microservice.rb +60 -46
  27. data/lib/openc3/microservices/text_log_microservice.rb +1 -2
  28. data/lib/openc3/models/cvt_model.rb +24 -13
  29. data/lib/openc3/models/db_sharded_model.rb +110 -0
  30. data/lib/openc3/models/interface_model.rb +9 -0
  31. data/lib/openc3/models/interface_status_model.rb +33 -3
  32. data/lib/openc3/models/metric_model.rb +96 -37
  33. data/lib/openc3/models/microservice_model.rb +7 -0
  34. data/lib/openc3/models/microservice_status_model.rb +30 -3
  35. data/lib/openc3/models/plugin_model.rb +9 -1
  36. data/lib/openc3/models/python_package_model.rb +1 -1
  37. data/lib/openc3/models/reaction_model.rb +27 -9
  38. data/lib/openc3/models/reingest_job_model.rb +153 -0
  39. data/lib/openc3/models/scope_model.rb +3 -2
  40. data/lib/openc3/models/script_status_model.rb +4 -20
  41. data/lib/openc3/models/target_model.rb +113 -100
  42. data/lib/openc3/models/trigger_model.rb +24 -7
  43. data/lib/openc3/packets/packet_config.rb +4 -1
  44. data/lib/openc3/script/api_shared.rb +39 -2
  45. data/lib/openc3/script/calendar.rb +32 -10
  46. data/lib/openc3/script/extract.rb +46 -13
  47. data/lib/openc3/script/script.rb +2 -2
  48. data/lib/openc3/script/script_runner.rb +4 -4
  49. data/lib/openc3/script/telemetry.rb +3 -3
  50. data/lib/openc3/script/web_socket_api.rb +29 -22
  51. data/lib/openc3/system/system.rb +20 -3
  52. data/lib/openc3/topics/command_decom_topic.rb +4 -2
  53. data/lib/openc3/topics/command_topic.rb +8 -5
  54. data/lib/openc3/topics/decom_interface_topic.rb +31 -11
  55. data/lib/openc3/topics/interface_topic.rb +88 -27
  56. data/lib/openc3/topics/limits_event_topic.rb +62 -41
  57. data/lib/openc3/topics/router_topic.rb +61 -21
  58. data/lib/openc3/topics/system_events_topic.rb +18 -1
  59. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -1
  60. data/lib/openc3/topics/telemetry_topic.rb +4 -2
  61. data/lib/openc3/topics/topic.rb +77 -5
  62. data/lib/openc3/utilities/aws_bucket.rb +2 -0
  63. data/lib/openc3/utilities/cli_generator.rb +3 -2
  64. data/lib/openc3/utilities/ctrf.rb +231 -0
  65. data/lib/openc3/utilities/metric.rb +15 -1
  66. data/lib/openc3/utilities/questdb_client.rb +177 -40
  67. data/lib/openc3/utilities/reingest_job.rb +377 -0
  68. data/lib/openc3/utilities/ruby_lex_utils.rb +2 -0
  69. data/lib/openc3/utilities/store_autoload.rb +78 -52
  70. data/lib/openc3/utilities/store_queued.rb +20 -12
  71. data/lib/openc3/version.rb +5 -5
  72. data/templates/plugin/plugin.gemspec +13 -1
  73. data/templates/tool_angular/package.json +2 -2
  74. data/templates/tool_react/package.json +1 -1
  75. data/templates/tool_svelte/package.json +1 -1
  76. data/templates/tool_vue/package.json +3 -4
  77. data/templates/tool_vue/src/router.js +2 -2
  78. data/templates/widget/package.json +2 -2
  79. metadata +8 -3
@@ -19,18 +19,90 @@ require 'openc3/utilities/store'
19
19
 
20
20
  module OpenC3
21
21
  class Topic
22
- # Delegate all unknown class methods to delegate to the EphemeralStore
22
+ # Delegate all unknown class methods to EphemeralStore db_shard 0 (system-level topics)
23
23
  def self.method_missing(message, *args, **kwargs, &block)
24
24
  EphemeralStore.public_send(message, *args, **kwargs, &block)
25
25
  end
26
26
 
27
- def self.clear_topics(topics, maxlen = 0)
28
- topics.each { |topic| EphemeralStore.xtrim(topic, maxlen) }
27
+ def self.clear_topics(topics, maxlen = 0, db_shard: 0)
28
+ store = EphemeralStore.instance(db_shard: db_shard)
29
+ topics.each { |topic| store.xtrim(topic, maxlen) }
29
30
  end
30
31
 
31
- def self.get_cnt(topic)
32
- _, packet = EphemeralStore.get_newest_message(topic)
32
+ def self.get_cnt(topic, db_shard: 0)
33
+ _, packet = EphemeralStore.instance(db_shard: db_shard).get_newest_message(topic)
33
34
  packet ? packet["received_count"].to_i : 0
34
35
  end
36
+
37
+ # DB_Shard-aware topic methods for target-specific streams.
38
+ # These explicitly route to the correct EphemeralStore db_shard.
39
+
40
+ def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', db_shard: 0)
41
+ EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate)
42
+ end
43
+
44
+ def self.read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil, db_shard: 0, &block)
45
+ EphemeralStore.instance(db_shard: db_shard).read_topics(topics, offsets, timeout_ms, count, &block)
46
+ end
47
+
48
+ def self.get_newest_message(topic, db_shard: 0)
49
+ EphemeralStore.instance(db_shard: db_shard).get_newest_message(topic)
50
+ end
51
+
52
+ def self.get_oldest_message(topic, db_shard: 0)
53
+ EphemeralStore.instance(db_shard: db_shard).get_oldest_message(topic)
54
+ end
55
+
56
+ def self.get_last_offset(topic, db_shard: 0)
57
+ EphemeralStore.instance(db_shard: db_shard).get_last_offset(topic)
58
+ end
59
+
60
+ def self.update_topic_offsets(topics, db_shard: 0)
61
+ EphemeralStore.instance(db_shard: db_shard).update_topic_offsets(topics)
62
+ end
63
+
64
+ def self.trim_topic(topic, minid, approximate = true, limit: 0, db_shard: 0)
65
+ EphemeralStore.instance(db_shard: db_shard).trim_topic(topic, minid, approximate, limit: limit)
66
+ end
67
+
68
+ def self.del(topic, db_shard: 0)
69
+ EphemeralStore.instance(db_shard: db_shard).del(topic)
70
+ end
71
+
72
+ # Group topics by db_shard. Each topic's target name is extracted and looked up.
73
+ # Topics matching target_pattern are db_sharded; others go to db_shard 0.
74
+ # @param topics [Array<String>] List of topic strings
75
+ # @param target_pattern [String] Substring to identify target-specific topics (e.g. 'CMD}TARGET__', '__TELEMETRY__')
76
+ # @param scope [String] Scope name for db_shard lookup
77
+ # @return [Hash] { db_shard => [topic, ...] }
78
+ def self.group_topics_by_db_shard(topics, target_pattern:, scope:)
79
+ groups = {}
80
+ topics.each do |topic|
81
+ if topic.include?(target_pattern)
82
+ target_name = topic.match(/__\{?([^}_]+)\}?__/)[1] rescue nil
83
+ # Handle CMD}TARGET__ pattern where target is after TARGET__
84
+ target_name = topic.split('TARGET__')[1] if target_pattern.include?('TARGET__') && target_name.nil?
85
+ db_shard = (Store.db_shard_for_target(target_name, scope: scope) || 0).to_i
86
+ else
87
+ db_shard = 0
88
+ end
89
+ groups[db_shard] ||= []
90
+ groups[db_shard] << topic
91
+ end
92
+ groups
93
+ end
94
+
95
+ # Check if all db_shard groups resolve to a single db_shard (fast path).
96
+ def self.all_same_db_shard?(db_shard_groups)
97
+ db_shard_groups.length <= 1
98
+ end
99
+
100
+ # Build the ACK topic from a command/router topic and write the ack.
101
+ def self.write_ack(topic, result, msg_id, db_shard: 0)
102
+ ack_topic = topic.split("__")
103
+ ack_topic[1] = 'ACK' + ack_topic[1]
104
+ ack_topic = ack_topic.join("__")
105
+ Topic.write_topic(ack_topic, { 'result' => result, 'id' => msg_id }, '*', 100, db_shard: db_shard)
106
+ end
35
107
  end
36
108
  end
@@ -177,6 +177,7 @@ module OpenC3
177
177
  @client.put_bucket_policy(options)
178
178
  rescue Aws::S3::Errors::NotImplemented, Aws::S3::Errors::ServiceError, Aws::S3::Errors::InternalError => e
179
179
  Logger.warn("put_bucket_policy for #{config_bucket} not supported by S3 backend: #{e.message}")
180
+ Logger.warn("Policy applied:\n#{config_policy}")
180
181
  end
181
182
 
182
183
  begin
@@ -186,6 +187,7 @@ module OpenC3
186
187
  @client.put_bucket_policy(options)
187
188
  rescue Aws::S3::Errors::NotImplemented, Aws::S3::Errors::ServiceError, Aws::S3::Errors::InternalError => e
188
189
  Logger.warn("put_bucket_policy for #{logs_bucket} not supported by S3 backend: #{e.message}")
190
+ Logger.warn("Policy applied:\n#{logs_policy}")
189
191
  end
190
192
  end
191
193
 
@@ -141,8 +141,9 @@ module OpenC3
141
141
  abort("Usage: cli generate #{args[0]} <NAME> (--ruby or --python)")
142
142
  end
143
143
 
144
- # Create the local variables
145
- plugin = args[1].downcase.gsub(/_+|-+/, '-')
144
+ # Create the local variables that are used in process_template below (see openc3/templates/plugin/plugin.gemspec as an example)
145
+ plugin_orig = args[1]
146
+ plugin = plugin_orig.downcase.gsub(/_+|-+/, '-')
146
147
  plugin_name = "openc3-cosmos-#{plugin}"
147
148
  if File.exist?(plugin_name)
148
149
  abort("Plugin #{plugin_name} already exists!")
@@ -0,0 +1,231 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2026 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is distributed in the hope that it will be useful,
7
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
8
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ # See LICENSE.md for more details.
10
+ #
11
+ # This file may also be used under the terms of a commercial license
12
+ # if purchased from OpenC3, Inc.
13
+
14
+ require 'openc3/version'
15
+ require 'date'
16
+
17
+ module OpenC3
18
+ # Utility class for converting COSMOS script reports to CTRF (Common Test Report Format)
19
+ # See https://ctrf.io/docs/category/specification
20
+ class Ctrf
21
+ # Convert a COSMOS plain text script report to CTRF JSON format
22
+ # @param report_content [String] Plain text script report
23
+ # @param version [String] Version string to include in CTRF output (defaults to OpenC3::VERSION)
24
+ # @return [Hash] CTRF formatted report as a Ruby hash
25
+ def self.convert_report(report_content, version: OpenC3::VERSION)
26
+ lines = report_content.split("\n")
27
+ tests = []
28
+ summary = {}
29
+ settings = {}
30
+ in_settings = false
31
+ last_result = nil
32
+ in_summary = false
33
+
34
+ lines.each do |line|
35
+ next if line.nil?
36
+ line_clean = line.strip
37
+
38
+ if line_clean == 'Settings:'
39
+ in_settings = true
40
+ next
41
+ end
42
+
43
+ if in_settings
44
+ if line_clean.include?('Manual')
45
+ parts = line.split('=')
46
+ settings[:manual] = parts[1].strip if parts[1]
47
+ next
48
+ elsif line_clean.include?('Pause on Error')
49
+ parts = line.split('=')
50
+ settings[:pauseOnError] = parts[1].strip if parts[1]
51
+ next
52
+ elsif line_clean.include?('Continue After Error')
53
+ parts = line.split('=')
54
+ settings[:continueAfterError] = parts[1].strip if parts[1]
55
+ next
56
+ elsif line_clean.include?('Abort After Error')
57
+ parts = line.split('=')
58
+ settings[:abortAfterError] = parts[1].strip if parts[1]
59
+ next
60
+ elsif line_clean.include?('Loop =')
61
+ parts = line.split('=')
62
+ settings[:loop] = parts[1].strip if parts[1]
63
+ next
64
+ elsif line_clean.include?('Break Loop On Error')
65
+ parts = line.split('=')
66
+ settings[:breakLoopOnError] = parts[1].strip if parts[1]
67
+ in_settings = false
68
+ next
69
+ end
70
+ end
71
+
72
+ if line_clean == 'Results:'
73
+ last_result = line_clean
74
+ next
75
+ end
76
+
77
+ if last_result
78
+ # The first line should always have a timestamp and what it is executing
79
+ # Format: "2026-04-02T19:45:41.228209Z: Executing MySuite:ExampleGroup:script_2"
80
+ if last_result == 'Results:' and line_clean.include?("Executing")
81
+ # Split on first ': ' to separate timestamp from message
82
+ timestamp_and_msg = line_clean.split(': ', 2)
83
+ if timestamp_and_msg.length >= 2
84
+ timestamp = timestamp_and_msg[0]
85
+ begin
86
+ summary[:startTime] = DateTime.parse(timestamp).to_time.to_f * 1000
87
+ rescue Date::Error
88
+ # Skip malformed timestamps
89
+ end
90
+ end
91
+ last_result = line_clean
92
+ next
93
+ end
94
+
95
+ # Format: "2026-04-02T19:45:44.041472Z: ExampleGroup:script_2:PASS"
96
+ # Check if line contains a test result (but not Executing or Completed)
97
+ if !line_clean.include?("Executing") && !line_clean.include?("Completed")
98
+ # Try to parse as a test result line
99
+ timestamp_and_msg = line_clean.split(': ', 2)
100
+ if timestamp_and_msg.length >= 2
101
+ result_string = timestamp_and_msg[1]
102
+ # Must have at least 2 colons for group:name:status format
103
+ result_parts = result_string.split(':')
104
+ if result_parts.length >= 3
105
+ # Get start time from last_result - could be Executing line or previous test result
106
+ start_time = nil
107
+ if last_result
108
+ last_timestamp_and_msg = last_result.split(': ', 2)
109
+ if last_timestamp_and_msg.length >= 2
110
+ begin
111
+ start_time = DateTime.parse(last_timestamp_and_msg[0]).to_time.to_f * 1000
112
+ rescue Date::Error
113
+ # Skip malformed timestamps
114
+ end
115
+ end
116
+ end
117
+
118
+ # Parse current line timestamp
119
+ timestamp = timestamp_and_msg[0]
120
+ begin
121
+ end_time = DateTime.parse(timestamp).to_time.to_f * 1000
122
+ rescue Date::Error
123
+ last_result = line_clean
124
+ next # Skip lines with malformed timestamps
125
+ end
126
+
127
+ # Parse the test result: ExampleGroup:script_2:PASS
128
+ suite_group = result_parts[0]
129
+ name = result_parts[1]
130
+ status = result_parts[2]
131
+
132
+ format_status = case status
133
+ when 'PASS'
134
+ 'passed'
135
+ when 'SKIP'
136
+ 'skipped'
137
+ when 'FAIL'
138
+ 'failed'
139
+ else
140
+ 'unknown'
141
+ end
142
+
143
+ tests << {
144
+ name: "#{suite_group}:#{name}",
145
+ status: format_status,
146
+ duration: start_time ? (end_time - start_time) : 0,
147
+ }
148
+ last_result = line_clean
149
+ next
150
+ end
151
+ end
152
+ end
153
+
154
+ # Format: "2026-04-02T19:45:44.044982Z: Completed MySuite:ExampleGroup:script_2"
155
+ if line_clean.include?("Completed")
156
+ timestamp_and_msg = line_clean.split(': ', 2)
157
+ if timestamp_and_msg.length >= 2
158
+ timestamp = timestamp_and_msg[0]
159
+ begin
160
+ summary[:stopTime] = DateTime.parse(timestamp).to_time.to_f * 1000
161
+ rescue Date::Error
162
+ # Skip malformed timestamps
163
+ end
164
+ end
165
+ last_result = nil
166
+ next
167
+ end
168
+ end
169
+
170
+ if line_clean == '--- Test Summary ---'
171
+ in_summary = true
172
+ next
173
+ end
174
+
175
+ if in_summary
176
+ if line_clean.include?("Total Tests")
177
+ parts = line_clean.split(':')
178
+ summary[:total] = parts[1].to_i if parts[1]
179
+ end
180
+ if line_clean.include?("Pass:")
181
+ parts = line_clean.split(':')
182
+ summary[:passed] = parts[1].to_i if parts[1]
183
+ end
184
+ if line_clean.include?("Skip:")
185
+ parts = line_clean.split(':')
186
+ summary[:skipped] = parts[1].to_i if parts[1]
187
+ end
188
+ if line_clean.include?("Fail:")
189
+ parts = line_clean.split(':')
190
+ summary[:failed] = parts[1].to_i if parts[1]
191
+ end
192
+ end
193
+ end
194
+
195
+ # Build CTRF report
196
+ # See https://ctrf.io/docs/specification/root
197
+ return {
198
+ reportFormat: "CTRF",
199
+ results: {
200
+ # See https://ctrf.io/docs/specification/tool
201
+ tool: {
202
+ name: "COSMOS Script Runner",
203
+ version: version,
204
+ },
205
+ # See https://ctrf.io/docs/specification/summary
206
+ summary: {
207
+ tests: summary[:total],
208
+ passed: summary[:passed],
209
+ failed: summary[:failed],
210
+ pending: 0,
211
+ skipped: summary[:skipped],
212
+ other: 0,
213
+ start: summary[:startTime],
214
+ stop: summary[:stopTime],
215
+ },
216
+ # See https://ctrf.io/docs/specification/tests
217
+ tests: tests,
218
+ # See https://ctrf.io/docs/specification/extra
219
+ extra: {
220
+ manual: settings[:manual],
221
+ pauseOnError: settings[:pauseOnError],
222
+ continueAfterError: settings[:continueAfterError],
223
+ abortAfterError: settings[:abortAfterError],
224
+ loop: settings[:loop],
225
+ breakLoopOnError: settings[:breakLoopOnError],
226
+ },
227
+ },
228
+ }
229
+ end
230
+ end
231
+ end
@@ -37,15 +37,28 @@ module OpenC3
37
37
 
38
38
  attr_reader :microservice
39
39
  attr_reader :scope
40
+ attr_reader :db_shard
40
41
  attr_reader :data
41
42
  attr_reader :mutex
42
43
 
43
- def initialize(microservice:, scope:)
44
+ def initialize(microservice:, scope:, db_shard: nil)
44
45
  @scope = scope
45
46
  @microservice = microservice
46
47
  @data = {}
47
48
  @mutex = Mutex.new
48
49
 
50
+ if db_shard
51
+ @db_shard = db_shard
52
+ else
53
+ # Look up db_shard from MicroserviceModel
54
+ begin
55
+ json = Store.hget('openc3_microservices', microservice)
56
+ @db_shard = json ? JSON.parse(json)['db_shard'].to_i : 0
57
+ rescue
58
+ @db_shard = 0
59
+ end
60
+ end
61
+
49
62
  # Always make sure there is a update thread
50
63
  @@mutex.synchronize do
51
64
  @@instances << self
@@ -90,6 +103,7 @@ module OpenC3
90
103
  instance.mutex.synchronize do
91
104
  json = {}
92
105
  json['name'] = instance.microservice
106
+ json['db_shard'] = instance.db_shard
93
107
  values = instance.data
94
108
  json['values'] = values
95
109
  MetricModel.set(json, scope: instance.scope) if values.length > 0