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
|
@@ -19,6 +19,7 @@ module OpenC3
|
|
|
19
19
|
response_body = nil
|
|
20
20
|
begin
|
|
21
21
|
endpoint = "/openc3-api/plugins?scope=#{scope}"
|
|
22
|
+
endpoint += "&exclude_default=true" unless default
|
|
22
23
|
uri = URI.parse($api_server.generate_url + endpoint)
|
|
23
24
|
auth = $api_server.generate_auth
|
|
24
25
|
|
|
@@ -30,19 +31,11 @@ module OpenC3
|
|
|
30
31
|
http.request(request) do |response|
|
|
31
32
|
response_body = response.body
|
|
32
33
|
response.value() # Raises an HTTP error if the response is not 2xx (success)
|
|
33
|
-
|
|
34
|
-
if default
|
|
35
|
-
return plugins
|
|
36
|
-
else
|
|
37
|
-
return plugins.select do |plugin|
|
|
38
|
-
!plugin.include?('openc3-cosmos-tool-') and !plugin.include?('openc3-tool-base') and
|
|
39
|
-
!plugin.include?('openc3-cosmos-enterprise-tool-') and !plugin.include?('openc3-enterprise-tool-base')
|
|
40
|
-
end
|
|
41
|
-
end
|
|
34
|
+
return JSON.parse(response.body, allow_nan: true, create_additions: true)
|
|
42
35
|
end
|
|
43
36
|
end
|
|
44
37
|
rescue => e
|
|
45
|
-
raise "
|
|
38
|
+
raise "plugin_list failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
|
|
46
39
|
end
|
|
47
40
|
end
|
|
48
41
|
|
|
@@ -65,7 +58,7 @@ module OpenC3
|
|
|
65
58
|
end
|
|
66
59
|
end
|
|
67
60
|
rescue => e
|
|
68
|
-
raise "
|
|
61
|
+
raise "plugin_get failed due to #{e.formatted}\nURI:#{uri}\nResponse:\n#{response_body}"
|
|
69
62
|
end
|
|
70
63
|
end
|
|
71
64
|
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
require 'tempfile'
|
|
19
19
|
require 'net/http'
|
|
20
|
+
require 'base64'
|
|
21
|
+
require 'openc3/utilities/local_mode'
|
|
20
22
|
|
|
21
23
|
ENV['OPENC3_CLOUD'] ||= 'local'
|
|
22
24
|
|
|
@@ -53,6 +55,15 @@ module OpenC3
|
|
|
53
55
|
|
|
54
56
|
upload_path = "#{scope}/targets_modified/#{path}"
|
|
55
57
|
|
|
58
|
+
if OpenC3::LocalMode.local_only_name?(path)
|
|
59
|
+
# The local mode volume is only mounted in the cluster so from outside
|
|
60
|
+
# write it through the API which never sends it to the bucket
|
|
61
|
+
return _upload_api_file(upload_path, io_or_string, scope: scope) unless $openc3_in_cluster
|
|
62
|
+
puts "Writing local #{upload_path}"
|
|
63
|
+
OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
|
|
64
|
+
return nil
|
|
65
|
+
end
|
|
66
|
+
|
|
56
67
|
if ENV['OPENC3_LOCAL_MODE'] and $openc3_in_cluster
|
|
57
68
|
OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
|
|
58
69
|
io_or_string.rewind if io_or_string.respond_to?(:rewind)
|
|
@@ -91,22 +102,22 @@ module OpenC3
|
|
|
91
102
|
# @param original [Boolean] Whether to get the original or modified file
|
|
92
103
|
# @return [File|nil]
|
|
93
104
|
def get_target_file(path, original: false, scope: $openc3_scope)
|
|
105
|
+
# Local only targets only exist in the local mode volume so original doesn't apply
|
|
106
|
+
if OpenC3::LocalMode.local_only_name?(path)
|
|
107
|
+
# The local mode volume is only mounted in the cluster so from outside
|
|
108
|
+
# read it through the API which serves it from the volume
|
|
109
|
+
return _get_api_file("targets_modified/#{path}", scope: scope) unless $openc3_in_cluster
|
|
110
|
+
return _get_local_file(path, scope: scope)
|
|
111
|
+
end
|
|
112
|
+
|
|
94
113
|
part = "targets"
|
|
95
114
|
part += "_modified" unless original
|
|
96
115
|
# Loop to allow redo when switching from modified to original
|
|
97
116
|
loop do
|
|
98
117
|
begin
|
|
99
118
|
if part == "targets_modified" and ENV['OPENC3_LOCAL_MODE']
|
|
100
|
-
|
|
101
|
-
if
|
|
102
|
-
puts "Reading local #{scope}/#{part}/#{path}"
|
|
103
|
-
file = Tempfile.new('target', binmode: true)
|
|
104
|
-
file.filename = path
|
|
105
|
-
file.write(local_file.read)
|
|
106
|
-
local_file.close
|
|
107
|
-
file.rewind
|
|
108
|
-
return file
|
|
109
|
-
end
|
|
119
|
+
file = _get_local_file(path, scope: scope)
|
|
120
|
+
return file if file
|
|
110
121
|
end
|
|
111
122
|
|
|
112
123
|
return _get_storage_file("#{part}/#{path}", scope: scope)
|
|
@@ -125,7 +136,53 @@ module OpenC3
|
|
|
125
136
|
|
|
126
137
|
# These are helper methods ... should not be used directly
|
|
127
138
|
|
|
139
|
+
# Write a file through the API instead of a presigned URL
|
|
140
|
+
def _upload_api_file(path, io_or_string, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
|
|
141
|
+
data = String === io_or_string ? io_or_string : io_or_string.read
|
|
142
|
+
endpoint = "/openc3-api/storage/upload_file/#{path}"
|
|
143
|
+
puts "Writing #{path}"
|
|
144
|
+
response = $api_server.request('put', endpoint, query: { bucket: bucket },
|
|
145
|
+
data: { contents: Base64.strict_encode64(data) }, json: true, scope: scope)
|
|
146
|
+
if response.nil? || response.status != 200
|
|
147
|
+
raise "Failed to write #{path}"
|
|
148
|
+
end
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Read a file through the API instead of a presigned URL
|
|
153
|
+
# Returns nil if the file doesn't exist
|
|
154
|
+
def _get_api_file(path, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
|
|
155
|
+
endpoint = "/openc3-api/storage/download_file/#{scope}/#{path}"
|
|
156
|
+
response = $api_server.request('get', endpoint, query: { bucket: bucket }, scope: scope)
|
|
157
|
+
return nil if response && response.status == 404
|
|
158
|
+
if response.nil? || response.status != 200
|
|
159
|
+
raise "Failed to read #{scope}/#{path}"
|
|
160
|
+
end
|
|
161
|
+
puts "Reading #{scope}/#{path}"
|
|
162
|
+
json = JSON.parse(response.body, allow_nan: true, create_additions: true)
|
|
163
|
+
file = Tempfile.new('target', binmode: true)
|
|
164
|
+
file.filename = path
|
|
165
|
+
file.write(Base64.decode64(json['contents']))
|
|
166
|
+
file.rewind
|
|
167
|
+
return file
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def _get_local_file(path, scope: $openc3_scope)
|
|
171
|
+
local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
|
|
172
|
+
return nil unless local_file
|
|
173
|
+
puts "Reading local #{scope}/targets_modified/#{path}"
|
|
174
|
+
file = Tempfile.new('target', binmode: true)
|
|
175
|
+
file.filename = path
|
|
176
|
+
file.write(local_file.read)
|
|
177
|
+
local_file.close
|
|
178
|
+
file.rewind
|
|
179
|
+
return file
|
|
180
|
+
end
|
|
181
|
+
|
|
128
182
|
def _get_download_url(path, scope: $openc3_scope)
|
|
183
|
+
if OpenC3::LocalMode.local_only_name?(path)
|
|
184
|
+
raise "#{path} belongs to a local only target (OPENC3_LOCAL_ONLY_TARGETS) and has no bucket download URL"
|
|
185
|
+
end
|
|
129
186
|
targets = "targets_modified" # First try targets_modified
|
|
130
187
|
response = $api_server.request('get', "/openc3-api/storage/exists/#{scope}/#{targets}/#{path}", query: { bucket: 'OPENC3_CONFIG_BUCKET' }, scope: scope)
|
|
131
188
|
if response.status != 200
|
|
@@ -167,6 +224,20 @@ module OpenC3
|
|
|
167
224
|
return file
|
|
168
225
|
end
|
|
169
226
|
|
|
227
|
+
# Retrieve the file selected in open_bucket_dialog
|
|
228
|
+
# input is the hash returned by the frontend: { 'bucket', 'path', 'filename' }
|
|
229
|
+
def _get_bucket_dialog_file(input, scope: $openc3_scope)
|
|
230
|
+
bucket = input['bucket']
|
|
231
|
+
# Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
|
|
232
|
+
# _get_storage_file prepends scope, so strip it from the path to avoid doubling
|
|
233
|
+
path = input['path'].sub(/\A#{Regexp.escape(scope)}\//, '')
|
|
234
|
+
file = _get_storage_file(path, bucket: bucket, scope: scope)
|
|
235
|
+
file.filename = input['filename']
|
|
236
|
+
file.bucket = bucket
|
|
237
|
+
file.bucket_path = input['path']
|
|
238
|
+
file
|
|
239
|
+
end
|
|
240
|
+
|
|
170
241
|
def _get_uri(url)
|
|
171
242
|
if $openc3_in_cluster
|
|
172
243
|
case ENV.fetch('OPENC3_CLOUD', nil)
|
data/lib/openc3/script/tables.rb
CHANGED
|
@@ -22,11 +22,15 @@ module OpenC3
|
|
|
22
22
|
return _tables_handle_response(response, 'Failed to create binary')
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# save: true (the default) writes the report into the target's storage next to
|
|
26
|
+
# the binary so it can be read back with get_target_file(). Pass save: false to
|
|
27
|
+
# get the contents in the return value without creating a file.
|
|
28
|
+
def table_create_report(filename, definition, table_name: nil, save: true, scope: $openc3_scope)
|
|
26
29
|
post_data = {}
|
|
27
30
|
post_data['binary'] = filename
|
|
28
31
|
post_data['definition'] = definition
|
|
29
32
|
post_data['table_name'] = table_name if table_name
|
|
33
|
+
post_data['save'] = save
|
|
30
34
|
response = $api_server.request('post', '/openc3-api/tables/report', json: true, data: post_data, scope: scope)
|
|
31
35
|
return _tables_handle_response(response, 'Failed to create report')
|
|
32
36
|
end
|
data/lib/openc3/system/system.rb
CHANGED
|
@@ -78,7 +78,8 @@ module OpenC3
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# target_version can also be the actual hash used in the target_archives folder
|
|
81
|
-
|
|
81
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions
|
|
82
|
+
def self.setup_targets(target_names, base_dir, target_version: 'current', scope:, descriptions: true)
|
|
82
83
|
# Nothing to do if there are no targets
|
|
83
84
|
return if target_names.nil? or target_names.length == 0
|
|
84
85
|
if @@instance.nil?
|
|
@@ -118,7 +119,7 @@ module OpenC3
|
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
# Build System from targets
|
|
121
|
-
System.instance(target_names, targets_path)
|
|
122
|
+
System.instance(target_names, targets_path, descriptions: descriptions)
|
|
122
123
|
end
|
|
123
124
|
end
|
|
124
125
|
|
|
@@ -137,14 +138,15 @@ module OpenC3
|
|
|
137
138
|
#
|
|
138
139
|
# @param target_names [Array of target_names]
|
|
139
140
|
# @param target_config_dir Directory where target config folders are
|
|
141
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions
|
|
140
142
|
# @return [System] The System singleton
|
|
141
|
-
def self.instance(target_names = nil, target_config_dir = nil)
|
|
143
|
+
def self.instance(target_names = nil, target_config_dir = nil, descriptions: true)
|
|
142
144
|
return @@instance if @@instance
|
|
143
145
|
raise "System.instance parameters are required on first call" unless target_names and target_config_dir
|
|
144
146
|
|
|
145
147
|
@@instance_mutex.synchronize do
|
|
146
148
|
return @@instance if @@instance
|
|
147
|
-
@@instance ||= self.new(target_names, target_config_dir)
|
|
149
|
+
@@instance ||= self.new(target_names, target_config_dir, descriptions: descriptions)
|
|
148
150
|
@@post_instance_callbacks.each do |callback|
|
|
149
151
|
callback.call
|
|
150
152
|
end
|
|
@@ -171,10 +173,11 @@ module OpenC3
|
|
|
171
173
|
#
|
|
172
174
|
# @param target_names [Array of target names]
|
|
173
175
|
# @param target_config_dir Directory where target config folders are
|
|
174
|
-
|
|
176
|
+
# @param descriptions [Boolean] Whether to keep packet and item descriptions
|
|
177
|
+
def initialize(target_names, target_config_dir, descriptions: true)
|
|
175
178
|
OpenC3.add_to_search_path(target_config_dir, true) if target_config_dir
|
|
176
179
|
@targets = {}
|
|
177
|
-
@packet_config = PacketConfig.new
|
|
180
|
+
@packet_config = PacketConfig.new(descriptions: descriptions)
|
|
178
181
|
@commands = Commands.new(@packet_config)
|
|
179
182
|
@telemetry = Telemetry.new(@packet_config)
|
|
180
183
|
@limits = Limits.new(@packet_config)
|
|
@@ -58,7 +58,7 @@ module OpenC3
|
|
|
58
58
|
msg_hash['json_data'] = JSON.generate(json_hash.as_json, allow_nan: true)
|
|
59
59
|
msg_hash['extra'] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
60
60
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
61
|
-
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
|
|
61
|
+
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: Topic.stream_safety_minid)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def self.get_cmd_item(target_name, packet_name, param_name, type: :FORMATTED, scope: $openc3_scope)
|
|
@@ -35,7 +35,8 @@ module OpenC3
|
|
|
35
35
|
buffer: packet.buffer(false) }
|
|
36
36
|
msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
37
37
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
38
|
-
|
|
38
|
+
minid = Topic.log_stream_safety_minid(packet.target_name, :CMD, scope: scope)
|
|
39
|
+
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
# @param command [Hash] Command hash structure read to be written to a topic
|
|
@@ -84,7 +84,8 @@ module OpenC3
|
|
|
84
84
|
Topic.write_topic(topic, { 'target_name' => packet.target_name, 'cmd_name' => packet.packet_name, 'cmd_buffer' => packet.buffer(false) }, '*', 100)
|
|
85
85
|
elsif target_names.length == 1
|
|
86
86
|
topic = "{#{scope}__CMD}TARGET__#{target_names[0]}"
|
|
87
|
-
|
|
87
|
+
# Name the routed target so the interface identifies against it (and sends it as UNKNOWN if that fails)
|
|
88
|
+
Topic.write_topic(topic, { 'target_name' => target_names[0], 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100)
|
|
88
89
|
else
|
|
89
90
|
raise "No route for command: #{packet.target_name ? packet.target_name : 'UNKNOWN'} #{packet.packet_name ? packet.packet_name : 'UNKNOWN'}"
|
|
90
91
|
end
|
|
@@ -45,7 +45,7 @@ module OpenC3
|
|
|
45
45
|
}
|
|
46
46
|
msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
47
47
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
48
|
-
Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, db_shard: db_shard)
|
|
48
|
+
Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, minid: Topic.stream_safety_minid(id), db_shard: db_shard)
|
|
49
49
|
|
|
50
50
|
unless packet.stored
|
|
51
51
|
# Also update the current value table with the latest decommutated data
|
|
@@ -33,10 +33,11 @@ module OpenC3
|
|
|
33
33
|
msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
|
|
34
34
|
topic = "#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}"
|
|
35
35
|
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
36
|
+
minid = Topic.log_stream_safety_minid(packet.target_name, :TLM, scope: scope)
|
|
36
37
|
if queued
|
|
37
|
-
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
|
|
38
|
+
EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
|
|
38
39
|
else
|
|
39
|
-
Topic.write_topic(topic, msg_hash, db_shard: db_shard)
|
|
40
|
+
Topic.write_topic(topic, msg_hash, minid: minid, db_shard: db_shard)
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
end
|
data/lib/openc3/topics/topic.rb
CHANGED
|
@@ -16,9 +16,48 @@
|
|
|
16
16
|
# if purchased from OpenC3, Inc.
|
|
17
17
|
|
|
18
18
|
require 'openc3/utilities/store'
|
|
19
|
+
require 'openc3/utilities/logger'
|
|
19
20
|
|
|
20
21
|
module OpenC3
|
|
21
22
|
class Topic
|
|
23
|
+
# Stream trimming contract for the high rate target streams
|
|
24
|
+
# (TELEMETRY__, COMMAND__, DECOM__, DECOMCMD__):
|
|
25
|
+
#
|
|
26
|
+
# The streams are primarily trimmed by their consumers once the data is safely
|
|
27
|
+
# persisted elsewhere:
|
|
28
|
+
# * TELEMETRY__ / COMMAND__ are trimmed by the log microservice (LogWriter) once a
|
|
29
|
+
# log file has been moved to the bucket plus LogWriter::CLEANUP_DELAY.
|
|
30
|
+
# * DECOM__ / DECOMCMD__ are trimmed by the TSDB microservice which keeps
|
|
31
|
+
# TsdbMicroservice::TRIM_KEEP_MS of data.
|
|
32
|
+
#
|
|
33
|
+
# If a trimmer is absent (logging disabled, no TSDB) or falls far behind, the streams
|
|
34
|
+
# would grow without bound. As a safety net every write to these streams also passes
|
|
35
|
+
# an approximate XADD MINID so no entry older than the safety max age is retained.
|
|
36
|
+
# The max age is OPENC3_STREAM_MAX_AGE_SECONDS (default 600s, 0 or empty disables) and is
|
|
37
|
+
# never less than STREAM_MAX_AGE_MIN_SECONDS. For TELEMETRY__ / COMMAND__ the max age is
|
|
38
|
+
# never less than two log cycles plus the cleanup delay, because historical streaming reads
|
|
39
|
+
# data not yet in the bucket from the stream.
|
|
40
|
+
#
|
|
41
|
+
# When the minid is calculated from now (auto generated ids) it uses this host's clock while
|
|
42
|
+
# Redis generates the ids from its own clock. A writer clock ahead of Redis by more than the
|
|
43
|
+
# max age would trim the entire stream (including the entry being added) so hosts must be
|
|
44
|
+
# time synchronized (NTP).
|
|
45
|
+
STREAM_MAX_AGE_DEFAULT_SECONDS = 600
|
|
46
|
+
# Twice TsdbMicroservice::TRIM_KEEP_MS. Also protects against small clock offsets.
|
|
47
|
+
STREAM_MAX_AGE_MIN_SECONDS = 120
|
|
48
|
+
# LogWriter::CLEANUP_DELAY is defined from this
|
|
49
|
+
LOG_CLEANUP_DELAY_SECONDS = 60
|
|
50
|
+
# TargetModel default log cycle time
|
|
51
|
+
DEFAULT_LOG_CYCLE_TIME_SECONDS = 600
|
|
52
|
+
# How long to wait before retrying a failed log cycle time lookup
|
|
53
|
+
LOG_CYCLE_LOOKUP_RETRY_SECONDS = 60
|
|
54
|
+
# These are calculated on the write path of every packet so they are cached per process.
|
|
55
|
+
# The environment doesn't change while a microservice runs and the log cycle time only
|
|
56
|
+
# changes when the plugin (and therefore the microservices) are reinstalled.
|
|
57
|
+
@@stream_max_age_ms = nil
|
|
58
|
+
@@log_stream_max_age_ms = {}
|
|
59
|
+
@@log_cycle_lookup_retry_at = {}
|
|
60
|
+
|
|
22
61
|
# Delegate all unknown class methods to EphemeralStore db_shard 0 (system-level topics)
|
|
23
62
|
def self.method_missing(message, *args, **kwargs, &block)
|
|
24
63
|
EphemeralStore.public_send(message, *args, **kwargs, &block)
|
|
@@ -37,8 +76,121 @@ module OpenC3
|
|
|
37
76
|
# DB_Shard-aware topic methods for target-specific streams.
|
|
38
77
|
# These explicitly route to the correct EphemeralStore db_shard.
|
|
39
78
|
|
|
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)
|
|
79
|
+
def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', minid: nil, db_shard: 0)
|
|
80
|
+
EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate, minid: minid)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Float] Configured safety max age in seconds (<= 0 means disabled)
|
|
84
|
+
def self.stream_max_age_seconds
|
|
85
|
+
value = ENV.fetch('OPENC3_STREAM_MAX_AGE_SECONDS', nil)
|
|
86
|
+
return STREAM_MAX_AGE_DEFAULT_SECONDS if value.nil?
|
|
87
|
+
return 0.0 if value.strip.empty?
|
|
88
|
+
# Strictly parse and reject nan / inf, which would raise when converted to
|
|
89
|
+
# milliseconds on every write. A typo keeps the safety cap at the default
|
|
90
|
+
# rather than disabling it.
|
|
91
|
+
seconds = Float(value, exception: false)
|
|
92
|
+
return seconds if seconds&.finite?
|
|
93
|
+
Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of '#{value}' is invalid, using #{STREAM_MAX_AGE_DEFAULT_SECONDS}")
|
|
94
|
+
return STREAM_MAX_AGE_DEFAULT_SECONDS
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Integer] Cached safety max age in milliseconds (0 means disabled)
|
|
98
|
+
def self.stream_max_age_ms
|
|
99
|
+
@@stream_max_age_ms ||= begin
|
|
100
|
+
seconds = stream_max_age_seconds
|
|
101
|
+
if seconds <= 0
|
|
102
|
+
0
|
|
103
|
+
elsif seconds < STREAM_MAX_AGE_MIN_SECONDS
|
|
104
|
+
Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of #{seconds} is below the minimum, using #{STREAM_MAX_AGE_MIN_SECONDS}")
|
|
105
|
+
STREAM_MAX_AGE_MIN_SECONDS * 1000
|
|
106
|
+
else
|
|
107
|
+
(seconds * 1000).to_i
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Calculates the MINID to pass to XADD to cap the age of entries in a target stream.
|
|
113
|
+
# The cap is anchored to the id being written (or now for auto generated ids) so an
|
|
114
|
+
# explicitly id'd entry (e.g. decom reusing the raw packet id) is never trimmed by its own add.
|
|
115
|
+
#
|
|
116
|
+
# @param id [String] The id being written ('*' or nil for auto generation)
|
|
117
|
+
# @param max_age_ms [Integer] Max age to retain (<= 0 means disabled)
|
|
118
|
+
# @return [String|nil] The minid or nil if the safety cap is disabled
|
|
119
|
+
def self.stream_safety_minid(id = '*', max_age_ms: stream_max_age_ms)
|
|
120
|
+
return nil if max_age_ms <= 0
|
|
121
|
+
if id.nil? or id == '*'
|
|
122
|
+
base_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
123
|
+
else
|
|
124
|
+
base_ms = id.to_s.to_i # Stops at the '-' sequence separator
|
|
125
|
+
end
|
|
126
|
+
minid_ms = base_ms - max_age_ms
|
|
127
|
+
return nil if minid_ms <= 0
|
|
128
|
+
return minid_ms.to_s
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Calculates the MINID for a TELEMETRY__ / COMMAND__ stream write which also never
|
|
132
|
+
# trims younger than log_stream_min_age_seconds. The resulting max age is cached per stream.
|
|
133
|
+
#
|
|
134
|
+
# @param target_name [String] Target name
|
|
135
|
+
# @param cmd_or_tlm [Symbol] :CMD or :TLM
|
|
136
|
+
# @return [String|nil] The minid or nil if the safety cap is disabled
|
|
137
|
+
def self.log_stream_safety_minid(target_name, cmd_or_tlm, scope:)
|
|
138
|
+
target_cache = ((@@log_stream_max_age_ms[scope] ||= {})[target_name] ||= {})
|
|
139
|
+
max_age_ms = target_cache[cmd_or_tlm]
|
|
140
|
+
unless max_age_ms
|
|
141
|
+
max_age_ms = log_stream_max_age_ms(target_name, cmd_or_tlm, scope: scope)
|
|
142
|
+
return nil unless max_age_ms # Log cycle time unknown so don't cap yet
|
|
143
|
+
target_cache[cmd_or_tlm] = max_age_ms
|
|
144
|
+
end
|
|
145
|
+
return stream_safety_minid(max_age_ms: max_age_ms)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @param target_name [String] Target name
|
|
149
|
+
# @param cmd_or_tlm [Symbol] :CMD or :TLM
|
|
150
|
+
# @return [Integer|nil] Max age in milliseconds for a TELEMETRY__ / COMMAND__ stream (0 means
|
|
151
|
+
# disabled) or nil if the log cycle time couldn't be determined. Guessing the log cycle time
|
|
152
|
+
# could trim data historical streaming still needs, so the stream isn't capped until a
|
|
153
|
+
# lookup (retried every LOG_CYCLE_LOOKUP_RETRY_SECONDS) succeeds.
|
|
154
|
+
def self.log_stream_max_age_ms(target_name, cmd_or_tlm, scope:)
|
|
155
|
+
max_age_ms = stream_max_age_ms
|
|
156
|
+
return max_age_ms if max_age_ms <= 0
|
|
157
|
+
key = [scope, target_name, cmd_or_tlm]
|
|
158
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
retry_at = @@log_cycle_lookup_retry_at[key]
|
|
160
|
+
return nil if retry_at and now < retry_at
|
|
161
|
+
begin
|
|
162
|
+
min_age_ms = log_stream_min_age_seconds(target_name, cmd_or_tlm, scope: scope) * 1000
|
|
163
|
+
rescue => e
|
|
164
|
+
Logger.warn("Unable to determine log cycle time for #{target_name} #{cmd_or_tlm}, stream age not capped: #{e.message}")
|
|
165
|
+
@@log_cycle_lookup_retry_at[key] = now + LOG_CYCLE_LOOKUP_RETRY_SECONDS
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
@@log_cycle_lookup_retry_at.delete(key)
|
|
169
|
+
return [max_age_ms, min_age_ms].max
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Minimum age to retain in a TELEMETRY__ / COMMAND__ stream so the log microservice
|
|
173
|
+
# and historical streaming always have the data not yet moved to the bucket.
|
|
174
|
+
#
|
|
175
|
+
# @param target_name [String] Target name
|
|
176
|
+
# @param cmd_or_tlm [Symbol] :CMD or :TLM
|
|
177
|
+
# @return [Integer] Minimum age in seconds
|
|
178
|
+
# @raise If the TargetModel lookup fails
|
|
179
|
+
def self.log_stream_min_age_seconds(target_name, cmd_or_tlm, scope:)
|
|
180
|
+
require 'openc3/models/target_model'
|
|
181
|
+
cycle_time = DEFAULT_LOG_CYCLE_TIME_SECONDS
|
|
182
|
+
model = TargetModel.get(name: target_name, scope: scope)
|
|
183
|
+
if model
|
|
184
|
+
value = model[cmd_or_tlm == :CMD ? 'cmd_log_cycle_time' : 'tlm_log_cycle_time'].to_i
|
|
185
|
+
cycle_time = value if value > 0
|
|
186
|
+
end
|
|
187
|
+
return 2 * (cycle_time + LOG_CLEANUP_DELAY_SECONDS)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.clear_stream_max_age_cache
|
|
191
|
+
@@stream_max_age_ms = nil
|
|
192
|
+
@@log_stream_max_age_ms = {}
|
|
193
|
+
@@log_cycle_lookup_retry_at = {}
|
|
42
194
|
end
|
|
43
195
|
|
|
44
196
|
def self.read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil, db_shard: 0, &block)
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
# This file may also be used under the terms of a commercial license
|
|
12
12
|
# if purchased from OpenC3, Inc.
|
|
13
13
|
|
|
14
|
+
require 'open3'
|
|
15
|
+
|
|
14
16
|
module OpenC3
|
|
15
17
|
class CliGenerator
|
|
16
18
|
GENERATORS = %w(plugin target microservice widget conversion processor limits_response tool tool_vue tool_angular tool_react tool_svelte command_validator)
|
|
@@ -392,9 +394,26 @@ module OpenC3
|
|
|
392
394
|
end
|
|
393
395
|
FileUtils.mkdir(plugin_name)
|
|
394
396
|
Dir.chdir(plugin_name) # Change to the plugin path to make copying easier
|
|
397
|
+
# Every installed plugin's lib/ goes on sys.path, so python code shared
|
|
398
|
+
# across the plugin lives in a package named after the plugin rather than
|
|
399
|
+
# directly in lib/, where a same-named module in another plugin collides.
|
|
400
|
+
package_name = plugin_name.gsub(/[^a-z0-9]+/, '_')
|
|
395
401
|
|
|
396
402
|
process_template("#{TEMPLATES_DIR}/plugin", binding) do |filename|
|
|
397
403
|
filename.sub!("plugin.gemspec", "#{plugin_name}.gemspec")
|
|
404
|
+
filename.sub!("lib/PACKAGE", "lib/#{package_name}")
|
|
405
|
+
# Only python plugins get a pyproject.toml. Shipping one in every plugin
|
|
406
|
+
# would make install_phase2 take the python dependency path (and build a
|
|
407
|
+
# per-plugin venv) for plugins that have no python code at all.
|
|
408
|
+
#
|
|
409
|
+
# lib/ comes with it because pyproject.toml sets ty's extra-paths to
|
|
410
|
+
# lib/, and ty exits non-zero rather than warning when a configured path
|
|
411
|
+
# does not exist. The package's __init__.py does reach the gem, which is
|
|
412
|
+
# harmless: the gem's pyproject.toml already sets needs_dependencies.
|
|
413
|
+
if @@language != 'py'
|
|
414
|
+
next true if filename == 'pyproject.toml'
|
|
415
|
+
next true if filename == 'lib' or filename.start_with?('lib/')
|
|
416
|
+
end
|
|
398
417
|
false
|
|
399
418
|
end
|
|
400
419
|
|
|
@@ -409,10 +428,28 @@ module OpenC3
|
|
|
409
428
|
end
|
|
410
429
|
end
|
|
411
430
|
|
|
431
|
+
write_python_version if @@language == 'py'
|
|
432
|
+
|
|
412
433
|
puts "Plugin #{plugin_name} successfully generated!"
|
|
413
434
|
return plugin_name
|
|
414
435
|
end
|
|
415
436
|
|
|
437
|
+
# Pin the plugin's local uv environment to the Python minor version COSMOS
|
|
438
|
+
# runs, read from the interpreter itself rather than hardcoded in the
|
|
439
|
+
# template: it differs between images (Debian vs UBI) and moves with the base
|
|
440
|
+
# image. `cli generate` runs in the cmd-tlm-api image, which sets
|
|
441
|
+
# OPENC3_PYTHON_BIN. Outside an image (e.g. the gem installed on a dev
|
|
442
|
+
# machine) there is no COSMOS Python to match, so no file is written.
|
|
443
|
+
def self.write_python_version
|
|
444
|
+
python_bin = ENV.fetch('OPENC3_PYTHON_BIN', nil)
|
|
445
|
+
return if python_bin.nil? || !File.executable?(python_bin)
|
|
446
|
+
version, status = Open3.capture2(python_bin, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])')
|
|
447
|
+
return unless status.success? && version.strip =~ /\A\d+\.\d+\z/
|
|
448
|
+
File.write('.python-version', "#{version.strip}\n")
|
|
449
|
+
rescue SystemCallError
|
|
450
|
+
nil
|
|
451
|
+
end
|
|
452
|
+
|
|
416
453
|
def self.generate_target(args)
|
|
417
454
|
validate_language_inheriting_args!('target', args,
|
|
418
455
|
example: ['cli generate target EXAMPLE --ruby', 'Creates: targets/EXAMPLE/'],
|
|
@@ -447,18 +484,33 @@ module OpenC3
|
|
|
447
484
|
end
|
|
448
485
|
gemspec_filename = Dir['*.gemspec'][0]
|
|
449
486
|
gemspec = File.read(gemspec_filename)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
#
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
487
|
+
# Older COSMOS versions wrote a singular python_dep_file block that never
|
|
488
|
+
# ships uv.lock, and a second python target nested another copy of it.
|
|
489
|
+
# Strip every copy plus its use on the s.files line so the rewrite below
|
|
490
|
+
# replaces it. A hand-edited block that no longer matches leaves some
|
|
491
|
+
# python_dep_file reference behind, so the gemspec is kept as it was.
|
|
492
|
+
legacy_block = /^ *# Prefer pyproject\.toml over requirements\.txt\n *python_dep_file = if File\.exist\?\('pyproject\.toml'\)\n *'pyproject\.toml'\n *else\n *'requirements\.txt'\n *end\n/
|
|
493
|
+
upgraded = gemspec.gsub(legacy_block, '').sub(' + [python_dep_file]', '')
|
|
494
|
+
gemspec = upgraded unless upgraded.include?('python_dep_file')
|
|
495
|
+
# Skip when a previous python target already rewrote s.files: the line it
|
|
496
|
+
# wrote matches the pattern below too, so rewriting again would nest a
|
|
497
|
+
# second dependency block. python_dep_file also covers a legacy block
|
|
498
|
+
# the upgrade above left alone. Also skip a gemspec from the
|
|
499
|
+
# python plugin template, which already ships pyproject.toml and uv.lock.
|
|
500
|
+
unless gemspec.include?('python_dep_file') || gemspec.include?('pyproject.toml')
|
|
501
|
+
gemspec.gsub!(/^ *s\.files = Dir\.glob.*\n/) do
|
|
502
|
+
<<~RUBY.gsub(/^/, ' ')
|
|
503
|
+
# Prefer pyproject.toml (plus uv.lock if committed) over requirements.txt
|
|
504
|
+
python_dep_files = if File.exist?('pyproject.toml')
|
|
505
|
+
['pyproject.toml'] + Dir.glob('uv.lock')
|
|
506
|
+
else
|
|
507
|
+
['requirements.txt']
|
|
508
|
+
end
|
|
509
|
+
s.files = Dir.glob("{targets,lib,public,tools,microservices}/**/*") + %w(Rakefile README.md LICENSE.md plugin.txt) + python_dep_files
|
|
510
|
+
RUBY
|
|
511
|
+
end
|
|
512
|
+
File.write(gemspec_filename, gemspec)
|
|
460
513
|
end
|
|
461
|
-
File.write(gemspec_filename, gemspec)
|
|
462
514
|
|
|
463
515
|
target_txt_filename = "targets/#{target_name}/target.txt"
|
|
464
516
|
target_txt = File.read(target_txt_filename)
|
|
@@ -626,8 +678,8 @@ RUBY
|
|
|
626
678
|
arguments: common_args,
|
|
627
679
|
notes: common_note,
|
|
628
680
|
example: [
|
|
629
|
-
"cli generate #{args[0]} '
|
|
630
|
-
"Creates: tools/
|
|
681
|
+
"cli generate #{args[0]} 'Mission Planner'",
|
|
682
|
+
"Creates: tools/missionplanner/ (#{framework}-based)",
|
|
631
683
|
],
|
|
632
684
|
in_plugin_extra: 'For other tool types, see: cli generate tool --help',
|
|
633
685
|
docs: docs_url,
|
|
@@ -650,8 +702,8 @@ RUBY
|
|
|
650
702
|
],
|
|
651
703
|
},
|
|
652
704
|
example: [
|
|
653
|
-
"cli generate tool '
|
|
654
|
-
'Creates: tools/
|
|
705
|
+
"cli generate tool 'Mission Planner'",
|
|
706
|
+
'Creates: tools/missionplanner/',
|
|
655
707
|
],
|
|
656
708
|
docs: docs_url,
|
|
657
709
|
exit_code: (args[1].nil? ? 1 : 0),
|