openc3 7.1.0 → 7.2.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 +3 -0
- data/data/config/command_modifiers.yaml +2 -2
- data/data/config/interface_modifiers.yaml +3 -1
- data/data/config/item_modifiers.yaml +10 -3
- data/data/config/microservice.yaml +3 -1
- data/data/config/plugins.yaml +1 -0
- data/lib/openc3/api/api.rb +1 -0
- data/lib/openc3/api/calendar_api.rb +183 -0
- data/lib/openc3/api/tlm_api.rb +6 -0
- data/lib/openc3/microservices/decom_microservice.rb +4 -2
- data/lib/openc3/microservices/microservice.rb +20 -5
- data/lib/openc3/models/plugin_model.rb +20 -8
- data/lib/openc3/models/queue_model.rb +36 -46
- data/lib/openc3/models/trigger_model.rb +1 -1
- data/lib/openc3/operators/operator.rb +34 -9
- data/lib/openc3/packets/packet_config.rb +17 -4
- data/lib/openc3/packets/parsers/xtce_parser.rb +23 -1
- data/lib/openc3/script/script.rb +4 -2
- data/lib/openc3/script/suite.rb +1 -1
- data/lib/openc3/script/web_socket_api.rb +5 -1
- data/lib/openc3/topics/command_topic.rb +1 -0
- data/lib/openc3/topics/telemetry_decom_topic.rb +1 -0
- data/lib/openc3/utilities/cli_generator.rb +434 -403
- data/lib/openc3/utilities/questdb_client.rb +51 -4
- data/lib/openc3/utilities/running_script.rb +49 -13
- data/lib/openc3/utilities/simulated_target.rb +4 -2
- data/lib/openc3/version.rb +5 -5
- data/templates/command_validator/command_validator.py +8 -10
- data/templates/command_validator/command_validator.rb +6 -9
- data/templates/microservice/microservices/TEMPLATE/microservice.py +9 -0
- data/templates/plugin/LICENSE.md +16 -4
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_svelte/package.json +1 -1
- data/templates/tool_vue/package.json +3 -3
- data/templates/widget/package.json +2 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90e17f6bf28158ea786646171220e3c23aca07cce30559b3e64a29f83dabb50d
|
|
4
|
+
data.tar.gz: 9a13e73273ba67e7d6003a9578a34a8b1c0abadea3b8d4d369f1fd3803a4f0ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eec5ccdb945747570717e5f60f9a89158d500d994f1fa5e6ebc81ed8bb263229950f10e1f277e0c68bf5d9fb531f3470354d45fda2164f7608c9ad22413b2022
|
|
7
|
+
data.tar.gz: a32f7675eacf6b41b345a4b5ae554292db011499d12a9e5e65173c514e5fac7e66da3bacea7ad5cf9a3738fcb96e3a82009997993fc70722d4a9ba4edd236261
|
data/bin/openc3cli
CHANGED
|
@@ -444,6 +444,9 @@ def unload_plugin(plugin_name, scope:)
|
|
|
444
444
|
plugin_model = OpenC3::PluginModel.get_model(name: plugin_name, scope: scope)
|
|
445
445
|
plugin_model.destroy
|
|
446
446
|
OpenC3::LocalMode.remove_local_plugin(plugin_name, scope: scope)
|
|
447
|
+
# Remove the backing gem now that no PluginModel references it,
|
|
448
|
+
# so it disappears from the admin Packages tab.
|
|
449
|
+
OpenC3::PluginModel.cleanup_gem(plugin_name, scope: scope)
|
|
447
450
|
OpenC3::Logger.info("PluginModel destroyed: #{plugin_name}", scope: scope)
|
|
448
451
|
rescue => e
|
|
449
452
|
abort("Error uninstalling plugin: #{scope}: #{plugin_name}: #{e.formatted}")
|
|
@@ -417,12 +417,12 @@ VALIDATOR:
|
|
|
417
417
|
return [False, "TGT PKT ITEM is 0"]
|
|
418
418
|
self.cmd_acpt_cnt = tlm("INST HEALTH_STATUS CMD_ACPT_CNT")
|
|
419
419
|
# Return true to indicate Success, false to indicate Failure,
|
|
420
|
-
# and
|
|
420
|
+
# and None to indicate Unknown. The second value is the optional message.
|
|
421
421
|
return [True, None]
|
|
422
422
|
|
|
423
423
|
def post_check(self, command):
|
|
424
424
|
wait_check(f"INST HEALTH_STATUS CMD_ACPT_CNT > {self.cmd_acpt_cnt}", 10)
|
|
425
425
|
# Return true to indicate Success, false to indicate Failure,
|
|
426
|
-
# and
|
|
426
|
+
# and None to indicate Unknown. The second value is the optional message.
|
|
427
427
|
return [True, None]
|
|
428
428
|
since: 5.19.0
|
|
@@ -212,7 +212,9 @@ WORK_DIR:
|
|
|
212
212
|
WORK_DIR '/openc3/lib/openc3/microservices'
|
|
213
213
|
PORT:
|
|
214
214
|
summary: Open port for the microservice
|
|
215
|
-
description:
|
|
215
|
+
description:
|
|
216
|
+
Kubernetes needs a Service to be applied to open a port so this is required for Kubernetes support
|
|
217
|
+
See [Exposing Microservices](/docs/guides/exposing-microservices) for more information.
|
|
216
218
|
since: 5.7.0
|
|
217
219
|
parameters:
|
|
218
220
|
- name: Number
|
|
@@ -111,20 +111,27 @@ CONVERTED_DATA:
|
|
|
111
111
|
values: \d+
|
|
112
112
|
LIMITS:
|
|
113
113
|
summary: Defines a set of limits for a telemetry item
|
|
114
|
-
description:
|
|
114
|
+
description: |
|
|
115
|
+
If limits are violated a message is printed in the Command and Telemetry Server
|
|
115
116
|
to indicate an item went out of limits. Other tools also use this information
|
|
116
117
|
to update displays with different colored telemetry items or other useful information.
|
|
117
118
|
The concept of "limits sets" is defined to allow for different limits values
|
|
118
119
|
in different environments. For example, you might want tighter or looser limits
|
|
119
120
|
on telemetry if your environment changes such as during thermal vacuum testing.
|
|
121
|
+
|
|
122
|
+
A DEFAULT limits set is required for every telemetry item with limits. If you
|
|
123
|
+
define additional named sets (e.g. TVAC), the DEFAULT set must be defined first.
|
|
124
|
+
Attempting to define a named set before DEFAULT will raise an error of the form
|
|
125
|
+
"DEFAULT limits set must be defined for TARGET PACKET ITEM before setting limits set NAME".
|
|
120
126
|
example: |
|
|
121
127
|
LIMITS DEFAULT 3 ENABLED -80.0 -70.0 60.0 80.0 -20.0 20.0
|
|
122
128
|
LIMITS TVAC 3 ENABLED -80.0 -30.0 30.0 80.0
|
|
123
129
|
parameters:
|
|
124
130
|
- name: Limits Set
|
|
125
131
|
required: true
|
|
126
|
-
description: Name of the limits set.
|
|
127
|
-
|
|
132
|
+
description: Name of the limits set. A DEFAULT set is required and must be
|
|
133
|
+
defined before any other named sets for this item. If you have no unique
|
|
134
|
+
limits sets use the keyword DEFAULT.
|
|
128
135
|
values: .+
|
|
129
136
|
- name: Persistence
|
|
130
137
|
required: true
|
|
@@ -40,7 +40,9 @@ MICROSERVICE:
|
|
|
40
40
|
WORK_DIR .
|
|
41
41
|
PORT:
|
|
42
42
|
summary: Open port for the microservice
|
|
43
|
-
description:
|
|
43
|
+
description:
|
|
44
|
+
Kubernetes needs a Service to be applied to open a port so this is required for Kubernetes support.
|
|
45
|
+
See [Exposing Microservices](/docs/guides/exposing-microservices) for more information.
|
|
44
46
|
since: 5.0.10
|
|
45
47
|
parameters:
|
|
46
48
|
- name: Number
|
data/data/config/plugins.yaml
CHANGED
data/lib/openc3/api/api.rb
CHANGED
|
@@ -0,0 +1,183 @@
|
|
|
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 'date'
|
|
15
|
+
require 'openc3/models/timeline_model'
|
|
16
|
+
require 'openc3/models/activity_model'
|
|
17
|
+
require 'openc3/topics/timeline_topic'
|
|
18
|
+
|
|
19
|
+
module OpenC3
|
|
20
|
+
module Api
|
|
21
|
+
# NOTE: These methods are intentionally NOT added to WHITELIST. Their signatures
|
|
22
|
+
# match openc3/lib/openc3/script/calendar.rb (no manual:/token: kwargs), so they
|
|
23
|
+
# cannot be dispatched via JSON-RPC (which auto-injects manual/token from headers).
|
|
24
|
+
# The script-side calendar methods reach the server through the timeline/activity
|
|
25
|
+
# HTTP controllers, which call these helpers after performing their own
|
|
26
|
+
# authorization.
|
|
27
|
+
|
|
28
|
+
# Returns an array of all timelines for the given scope.
|
|
29
|
+
def list_timelines(scope: $openc3_scope)
|
|
30
|
+
ret = []
|
|
31
|
+
TimelineModel.all.each do |timeline, value|
|
|
32
|
+
if scope == timeline.split('__')[0]
|
|
33
|
+
ret << value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
ret
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Creates a new timeline and deploys its microservice.
|
|
40
|
+
# @return [Hash] the created timeline as a hash
|
|
41
|
+
def create_timeline(name, color: nil, scope: $openc3_scope)
|
|
42
|
+
model = TimelineModel.new(name: name, color: color, scope: scope)
|
|
43
|
+
model.create()
|
|
44
|
+
model.deploy()
|
|
45
|
+
model.as_json()
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Hash, nil] the timeline as a hash, or nil if not found
|
|
49
|
+
def get_timeline(name, scope: $openc3_scope)
|
|
50
|
+
model = TimelineModel.get(name: name, scope: scope)
|
|
51
|
+
return nil if model.nil?
|
|
52
|
+
model.as_json()
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Updates the color of an existing timeline.
|
|
56
|
+
# @return [Hash, nil] the updated timeline as a hash, or nil if not found
|
|
57
|
+
def set_timeline_color(name, color, scope: $openc3_scope)
|
|
58
|
+
model = TimelineModel.get(name: name, scope: scope)
|
|
59
|
+
return nil if model.nil?
|
|
60
|
+
model.color = color
|
|
61
|
+
model.update()
|
|
62
|
+
model.notify(kind: 'updated')
|
|
63
|
+
model.as_json()
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Updates the execute flag of an existing timeline.
|
|
67
|
+
# @return [Hash, nil] the updated timeline as a hash, or nil if not found
|
|
68
|
+
def set_timeline_execute(name, enable, scope: $openc3_scope)
|
|
69
|
+
model = TimelineModel.get(name: name, scope: scope)
|
|
70
|
+
return nil if model.nil?
|
|
71
|
+
model.execute = enable
|
|
72
|
+
model.update()
|
|
73
|
+
model.notify(kind: 'updated')
|
|
74
|
+
model.as_json()
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Deletes a timeline (and optionally all of its activities when force is true).
|
|
78
|
+
# @return [Hash, nil] {'name' => name}, or nil if not found
|
|
79
|
+
def delete_timeline(name, force: false, scope: $openc3_scope)
|
|
80
|
+
model = TimelineModel.get(name: name, scope: scope)
|
|
81
|
+
return nil if model.nil?
|
|
82
|
+
TimelineModel.delete(name: name, scope: scope, force: force)
|
|
83
|
+
model.undeploy()
|
|
84
|
+
model.notify(kind: 'deleted')
|
|
85
|
+
{ 'name' => name }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Creates a new activity on the specified timeline.
|
|
89
|
+
# username is read from data['username'] if present and is used for the audit event.
|
|
90
|
+
# @return [Hash] the created activity as a hash
|
|
91
|
+
def create_timeline_activity(name, kind:, start:, stop:, data: {}, recurring: nil, scope: $openc3_scope)
|
|
92
|
+
data ||= {}
|
|
93
|
+
hash = {
|
|
94
|
+
kind: kind,
|
|
95
|
+
start: _cal_to_epoch(start),
|
|
96
|
+
stop: _cal_to_epoch(stop),
|
|
97
|
+
data: data,
|
|
98
|
+
}
|
|
99
|
+
if recurring
|
|
100
|
+
recurring = recurring.dup
|
|
101
|
+
if recurring['end']
|
|
102
|
+
recurring['end'] = _cal_to_epoch(recurring['end'])
|
|
103
|
+
end
|
|
104
|
+
hash[:recurring] = recurring
|
|
105
|
+
end
|
|
106
|
+
model = ActivityModel.from_json(hash, name: name, scope: scope)
|
|
107
|
+
model.create(username: data['username'])
|
|
108
|
+
model.as_json()
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Updates an existing activity on the specified timeline.
|
|
112
|
+
# @return [Hash, nil] the updated activity as a hash, or nil if not found
|
|
113
|
+
def update_timeline_activity(name, id:, kind:, start:, stop:, uuid:, data: {}, scope: $openc3_scope)
|
|
114
|
+
data ||= {}
|
|
115
|
+
model = ActivityModel.score(name: name, score: id.to_i, uuid: uuid, scope: scope)
|
|
116
|
+
return nil if model.nil?
|
|
117
|
+
model.update(
|
|
118
|
+
start: _cal_to_epoch(start),
|
|
119
|
+
stop: _cal_to_epoch(stop),
|
|
120
|
+
kind: kind,
|
|
121
|
+
data: data,
|
|
122
|
+
username: data['username'],
|
|
123
|
+
)
|
|
124
|
+
model.as_json()
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Hash, nil] the activity as a hash, or nil if not found
|
|
128
|
+
def get_timeline_activity(name, start, uuid, scope: $openc3_scope)
|
|
129
|
+
model = ActivityModel.score(name: name, score: start.to_i, uuid: uuid, scope: scope)
|
|
130
|
+
return nil if model.nil?
|
|
131
|
+
model.as_json()
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns activities on the timeline in the given window.
|
|
135
|
+
# When start/stop are nil, defaults to a window of [now - 7 days, now + 7 days].
|
|
136
|
+
# When limit is nil, defaults to one event per minute over the window.
|
|
137
|
+
# @return [Array<Hash>] the matching activities
|
|
138
|
+
def get_timeline_activities(name, start: nil, stop: nil, limit: nil, scope: $openc3_scope)
|
|
139
|
+
now = DateTime.now.new_offset(0)
|
|
140
|
+
start_score = start.nil? ? (now - 7).strftime('%s').to_i : _cal_to_epoch(start)
|
|
141
|
+
stop_score = stop.nil? ? (now + 7).strftime('%s').to_i : _cal_to_epoch(stop)
|
|
142
|
+
limit ||= ((stop_score - start_score) / 60).to_i
|
|
143
|
+
ActivityModel.get(name: name, scope: scope, start: start_score, stop: stop_score, limit: limit)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Removes an activity (or all members of its recurring group when recurring is truthy).
|
|
147
|
+
# @return [Integer] number of activities removed (0 indicates not found)
|
|
148
|
+
def delete_timeline_activity(name, start, uuid, recurring: nil, scope: $openc3_scope)
|
|
149
|
+
ActivityModel.destroy(name: name, scope: scope, score: start.to_i, uuid: uuid, recurring: recurring)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Integer] count of activities on the timeline
|
|
153
|
+
def count_timeline_activities(name, scope: $openc3_scope)
|
|
154
|
+
ActivityModel.count(name: name, scope: scope)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Commits an event to an existing activity.
|
|
158
|
+
# @return [Hash, nil] the activity as a hash, or nil if not found
|
|
159
|
+
def commit_timeline_activity(name, start, uuid, status:, message: nil, scope: $openc3_scope)
|
|
160
|
+
model = ActivityModel.score(name: name, score: start.to_i, uuid: uuid, scope: scope)
|
|
161
|
+
return nil if model.nil?
|
|
162
|
+
model.commit(status: status, message: message)
|
|
163
|
+
model.as_json()
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Convert a value to an epoch integer. Accepts Integer/Numeric (treated as already-epoch),
|
|
167
|
+
# numeric strings, and ISO-style date/time strings or DateTime/Time objects.
|
|
168
|
+
def _cal_to_epoch(value)
|
|
169
|
+
case value
|
|
170
|
+
when Integer
|
|
171
|
+
value
|
|
172
|
+
when Numeric
|
|
173
|
+
value.to_i
|
|
174
|
+
when DateTime, Time, Date
|
|
175
|
+
value.to_datetime.strftime('%s').to_i
|
|
176
|
+
else
|
|
177
|
+
s = value.to_s
|
|
178
|
+
return s.to_i if s.match?(/\A-?\d+\z/)
|
|
179
|
+
DateTime.parse(s).strftime('%s').to_i
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/openc3/api/tlm_api.rb
CHANGED
|
@@ -457,6 +457,12 @@ module OpenC3
|
|
|
457
457
|
alias subscribe_packet subscribe_packets
|
|
458
458
|
|
|
459
459
|
# Get packets based on ID returned from subscribe_packet.
|
|
460
|
+
# Packets are ordered within each subscribed packet stream (target/packet pair)
|
|
461
|
+
# but are NOT interleaved by time across streams. If chronological order across
|
|
462
|
+
# streams is required, sort the returned array by the 'time' field. Sorting only
|
|
463
|
+
# orders the current batch - packets across separate get_packets calls may still
|
|
464
|
+
# arrive out of order, so subscribers needing global ordering must buffer and merge
|
|
465
|
+
# across calls.
|
|
460
466
|
# @param id [String] ID returned from subscribe_packets or last call to get_packets
|
|
461
467
|
# @param block [Integer] Unused - Blocking must be implemented at the client
|
|
462
468
|
# @param count [Integer] Maximum number of packets to return from EACH packet stream
|
|
@@ -94,13 +94,15 @@ module OpenC3
|
|
|
94
94
|
@limits_event_topic = "#{@scope}__openc3_limits_events"
|
|
95
95
|
@topics << @limits_event_topic
|
|
96
96
|
Topic.update_topic_offsets(@topics, db_shard: @db_shard)
|
|
97
|
+
# Initialize before assigning limits_change_callback - sync_system below
|
|
98
|
+
# can fire the callback synchronously for any persisted-disabled items.
|
|
99
|
+
@limits_response_queue = Queue.new
|
|
100
|
+
@limits_response_thread = nil
|
|
97
101
|
System.telemetry.limits_change_callback = method(:limits_change_callback)
|
|
98
102
|
LimitsEventTopic.sync_system(scope: @scope)
|
|
99
103
|
@error_count = 0
|
|
100
104
|
@metric.set(name: 'decom_total', value: @count, type: 'counter')
|
|
101
105
|
@metric.set(name: 'decom_error_total', value: @error_count, type: 'counter')
|
|
102
|
-
@limits_response_queue = Queue.new
|
|
103
|
-
@limits_response_thread = nil
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def run
|
|
@@ -155,11 +155,26 @@ module OpenC3
|
|
|
155
155
|
|
|
156
156
|
prefix = "#{@scope}/microservices/#{@name}/"
|
|
157
157
|
file_count = 0
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
# Tolerate transient object store failures during startup. On a busy or
|
|
159
|
+
# underpowered cluster the bucket store (e.g. MinIO) can be briefly
|
|
160
|
+
# unreachable while many microservices start at once. Rather than crash
|
|
161
|
+
# and CrashLoopBackOff (which can outlast deploy timeouts), retry for a
|
|
162
|
+
# bounded time before giving up.
|
|
163
|
+
startup_timeout = (ENV['OPENC3_MICROSERVICE_STARTUP_BUCKET_TIMEOUT'] || 60).to_f
|
|
164
|
+
startup_deadline = Time.now + startup_timeout
|
|
165
|
+
begin
|
|
166
|
+
file_count = 0
|
|
167
|
+
client.list_objects(bucket: bucket, prefix: prefix).each do |object|
|
|
168
|
+
response_target = OpenC3.sanitize_path(File.join(@temp_dir, object.key.split(prefix)[-1]))
|
|
169
|
+
FileUtils.mkdir_p(File.dirname(response_target))
|
|
170
|
+
client.get_object(bucket: bucket, key: object.key, path: response_target)
|
|
171
|
+
file_count += 1
|
|
172
|
+
end
|
|
173
|
+
rescue => error
|
|
174
|
+
raise if Time.now >= startup_deadline
|
|
175
|
+
@logger.warn("Microservice #{@name} startup: bucket access failed (#{error.class}: #{error.message}); retrying for up to #{startup_timeout.to_i}s")
|
|
176
|
+
sleep(5)
|
|
177
|
+
retry
|
|
163
178
|
end
|
|
164
179
|
|
|
165
180
|
# Adjust @work_dir to microservice files downloaded if files and a relative path
|
|
@@ -158,12 +158,12 @@ module OpenC3
|
|
|
158
158
|
variables[current_variable_name]['description'] = params[0]
|
|
159
159
|
when 'VARIABLE_STATE'
|
|
160
160
|
usage = "#{keyword} <Display Text> <Value>"
|
|
161
|
-
parser.verify_num_parameters(
|
|
161
|
+
parser.verify_num_parameters(1, 2, usage)
|
|
162
162
|
unless current_variable_name
|
|
163
163
|
raise "VARIABLE_STATE must follow a VARIABLE definition"
|
|
164
164
|
end
|
|
165
165
|
variables[current_variable_name]['options'] ||= []
|
|
166
|
-
option = { 'value' => params[1], 'text' => params[0] }
|
|
166
|
+
option = { 'value' => params[1] || params[0], 'text' => params[0] }
|
|
167
167
|
variables[current_variable_name]['options'] << option
|
|
168
168
|
end
|
|
169
169
|
end
|
|
@@ -229,12 +229,13 @@ module OpenC3
|
|
|
229
229
|
plugin_model.homepage = pkg.spec.homepage
|
|
230
230
|
plugin_model.repository = pkg.spec.metadata['source_code_uri'] # this key because it's in the official gemspec examples
|
|
231
231
|
plugin_model.keywords = pkg.spec.metadata['openc3_store_keywords']&.split(/, ?/)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
232
|
+
# Resolve the plugin's store image path. The gemspec generated by
|
|
233
|
+
# `cli generate plugin` sets `openc3_store_image` to `public/store_img.png`
|
|
234
|
+
# but does not create that file, so we must verify existence regardless
|
|
235
|
+
# of whether the path came from metadata or the default — otherwise the
|
|
236
|
+
# frontend hits a 500 when fetching the missing file.
|
|
237
|
+
img_path = pkg.spec.metadata['openc3_store_image'] || 'public/store_img.png'
|
|
238
|
+
img_path = nil unless File.exist?(File.join(gem_path, img_path))
|
|
238
239
|
package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
|
|
239
240
|
plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
|
|
240
241
|
plugin_model.update() unless validate_only
|
|
@@ -525,5 +526,16 @@ module OpenC3
|
|
|
525
526
|
end
|
|
526
527
|
return result.sort
|
|
527
528
|
end
|
|
529
|
+
|
|
530
|
+
# Remove the backing gem for an unloaded plugin so it disappears from
|
|
531
|
+
# GemModel.names (and the admin Packages tab). Skips the removal if any
|
|
532
|
+
# other PluginModel (any scope, any counter) still references the gem,
|
|
533
|
+
# via the existing PluginModel.gem_names check inside GemModel.destroy.
|
|
534
|
+
def self.cleanup_gem(plugin_name, scope:)
|
|
535
|
+
gem_filename = plugin_name.split("__")[0]
|
|
536
|
+
OpenC3::GemModel.destroy(gem_filename, log_and_raise_needed_errors: false)
|
|
537
|
+
rescue => e
|
|
538
|
+
Logger.warn("Could not remove gem #{gem_filename}: #{e.message}", scope: scope)
|
|
539
|
+
end
|
|
528
540
|
end
|
|
529
541
|
end
|
|
@@ -44,34 +44,37 @@ module OpenC3
|
|
|
44
44
|
model = get_model(name: name, scope: scope)
|
|
45
45
|
raise QueueError, "Queue '#{name}' not found in scope '#{scope}'" unless model
|
|
46
46
|
|
|
47
|
-
if model.state
|
|
48
|
-
result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
|
|
49
|
-
if result.empty?
|
|
50
|
-
id = 1.0
|
|
51
|
-
else
|
|
52
|
-
id = result[0][1].to_f + 1
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Build command data with support for both formats
|
|
56
|
-
command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch, validate: validate, timeout: timeout }
|
|
57
|
-
if target_name && cmd_name
|
|
58
|
-
# New format: store target_name, cmd_name, and cmd_params separately
|
|
59
|
-
command_data[:target_name] = target_name
|
|
60
|
-
command_data[:cmd_name] = cmd_name
|
|
61
|
-
command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
|
|
62
|
-
elsif command
|
|
63
|
-
# Legacy format: store command string for backwards compatibility
|
|
64
|
-
command_data[:value] = command
|
|
65
|
-
else
|
|
66
|
-
raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
Store.zadd("#{scope}:#{name}", id, command_data.to_json)
|
|
70
|
-
model.notify(kind: 'command')
|
|
71
|
-
else
|
|
47
|
+
if model.state == 'DISABLE'
|
|
72
48
|
error_msg = command || "#{target_name} #{cmd_name}"
|
|
73
49
|
raise QueueError, "Queue '#{name}' is disabled. Command '#{error_msg}' not queued."
|
|
74
50
|
end
|
|
51
|
+
|
|
52
|
+
result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
|
|
53
|
+
id = result.empty? ? 1.0 : result[0][1].to_f + 1
|
|
54
|
+
|
|
55
|
+
command_data = build_command_data(username: username, command: command, target_name: target_name,
|
|
56
|
+
cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
|
|
57
|
+
Store.zadd("#{scope}:#{name}", id, command_data.to_json)
|
|
58
|
+
model.notify(kind: 'command')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build the hash that gets serialized into Redis. Always uses symbol keys so
|
|
62
|
+
# downstream code in this class can access values consistently. cmd_params is
|
|
63
|
+
# JSON-encoded as a string so binary data survives the round-trip via as_json.
|
|
64
|
+
def self.build_command_data(username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
|
|
65
|
+
command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch }
|
|
66
|
+
if target_name && cmd_name
|
|
67
|
+
command_data[:target_name] = target_name
|
|
68
|
+
command_data[:cmd_name] = cmd_name
|
|
69
|
+
command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
|
|
70
|
+
elsif command
|
|
71
|
+
command_data[:value] = command
|
|
72
|
+
else
|
|
73
|
+
raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
|
|
74
|
+
end
|
|
75
|
+
command_data[:validate] = validate unless validate.nil?
|
|
76
|
+
command_data[:timeout] = timeout unless timeout.nil?
|
|
77
|
+
command_data
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
attr_accessor :name, :state
|
|
@@ -115,49 +118,36 @@ module OpenC3
|
|
|
115
118
|
QueueTopic.write_notification(notification, scope: @scope)
|
|
116
119
|
end
|
|
117
120
|
|
|
118
|
-
def insert_command(id,
|
|
121
|
+
def insert_command(id: nil, username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
|
|
119
122
|
if @state == 'DISABLE'
|
|
120
|
-
|
|
121
|
-
command_name = command_data['value']
|
|
122
|
-
else
|
|
123
|
-
command_name = "#{command_data['target_name']} #{command_data['cmd_name']}"
|
|
124
|
-
end
|
|
123
|
+
command_name = command || "#{target_name} #{cmd_name}"
|
|
125
124
|
raise QueueError, "Queue '#{@name}' is disabled. Command '#{command_name}' not queued."
|
|
126
125
|
end
|
|
127
126
|
|
|
128
127
|
unless id
|
|
129
128
|
result = Store.zrevrange("#{@scope}:#{@name}", 0, 0, with_scores: true)
|
|
130
|
-
|
|
131
|
-
id = 1.0
|
|
132
|
-
else
|
|
133
|
-
id = result[0][1].to_f + 1
|
|
134
|
-
end
|
|
129
|
+
id = result.empty? ? 1.0 : result[0][1].to_f + 1
|
|
135
130
|
end
|
|
136
131
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
command_data['cmd_params'] = JSON.generate(command_data['cmd_params'].as_json, allow_nan: true)
|
|
140
|
-
end
|
|
132
|
+
command_data = self.class.build_command_data(username: username, command: command, target_name: target_name,
|
|
133
|
+
cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
|
|
141
134
|
Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
|
|
142
135
|
notify(kind: 'command')
|
|
143
136
|
end
|
|
144
137
|
|
|
145
|
-
def update_command(id:,
|
|
138
|
+
def update_command(id:, username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
|
|
146
139
|
if @state == 'DISABLE'
|
|
147
140
|
raise QueueError, "Queue '#{@name}' is disabled. Command at id #{id} not updated."
|
|
148
141
|
end
|
|
149
142
|
|
|
150
|
-
# Check if command exists at the given id
|
|
151
143
|
existing = Store.zrangebyscore("#{@scope}:#{@name}", id, id)
|
|
152
144
|
if existing.empty?
|
|
153
145
|
raise QueueError, "No command found at id #{id} in queue '#{@name}'"
|
|
154
146
|
end
|
|
155
147
|
|
|
156
|
-
# Remove the existing command and add the new one at the same id
|
|
157
148
|
Store.zremrangebyscore("#{@scope}:#{@name}", id, id)
|
|
158
|
-
command_data =
|
|
159
|
-
|
|
160
|
-
command_data[:timeout] = timeout unless timeout.nil?
|
|
149
|
+
command_data = self.class.build_command_data(username: username, command: command, target_name: target_name,
|
|
150
|
+
cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
|
|
161
151
|
Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
|
|
162
152
|
notify(kind: 'command')
|
|
163
153
|
end
|
|
@@ -238,6 +238,11 @@ module OpenC3
|
|
|
238
238
|
|
|
239
239
|
OperatorProcess.setup()
|
|
240
240
|
@cycle_time = (ENV['OPERATOR_CYCLE_TIME'] and ENV['OPERATOR_CYCLE_TIME'].to_f) || CYCLE_TIME # time in seconds
|
|
241
|
+
# Maximum number of new microservices to start per cycle. This spreads a
|
|
242
|
+
# large startup burst (e.g. installing a plugin with many targets) across
|
|
243
|
+
# multiple cycles so the shared services (object store, redis) aren't
|
|
244
|
+
# stampeded by every microservice connecting at once. 0 = no limit.
|
|
245
|
+
@max_start_per_cycle = (ENV['OPENC3_OPERATOR_MAX_START_PER_CYCLE'] || 5).to_i
|
|
241
246
|
|
|
242
247
|
@ruby_process_name = ENV['OPENC3_RUBY']
|
|
243
248
|
if RUBY_ENGINE != 'ruby'
|
|
@@ -262,10 +267,17 @@ module OpenC3
|
|
|
262
267
|
def start_new
|
|
263
268
|
@mutex.synchronize do
|
|
264
269
|
if @new_processes.length > 0
|
|
265
|
-
# Start
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
# Start at most @max_start_per_cycle processes this cycle; any
|
|
271
|
+
# remaining stay queued in @new_processes and start on later cycles.
|
|
272
|
+
# This avoids a startup stampede when many microservices appear at
|
|
273
|
+
# once (e.g. a plugin install) overwhelming the object store / redis.
|
|
274
|
+
start_names = @new_processes.keys
|
|
275
|
+
start_names = start_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
|
|
276
|
+
Logger.info("#{self.class} starting #{start_names.length} of #{@new_processes.length} new process(es)...")
|
|
277
|
+
start_names.each do |name|
|
|
278
|
+
@new_processes[name].start
|
|
279
|
+
@new_processes.delete(name)
|
|
280
|
+
end
|
|
269
281
|
end
|
|
270
282
|
end
|
|
271
283
|
end
|
|
@@ -273,12 +285,22 @@ module OpenC3
|
|
|
273
285
|
def respawn_changed
|
|
274
286
|
@mutex.synchronize do
|
|
275
287
|
if @changed_processes.length > 0
|
|
276
|
-
|
|
277
|
-
|
|
288
|
+
# Cycle at most @max_start_per_cycle changed microservices this cycle;
|
|
289
|
+
# any remaining stay queued in @changed_processes and are cycled on
|
|
290
|
+
# later cycles. This avoids a restart stampede when many microservices
|
|
291
|
+
# change at once (e.g. a configmap change) overwhelming shared
|
|
292
|
+
# services. Processes not yet cycled keep running until their turn.
|
|
293
|
+
cycle_names = @changed_processes.keys
|
|
294
|
+
cycle_names = cycle_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
|
|
295
|
+
cycle = @changed_processes.slice(*cycle_names)
|
|
296
|
+
Logger.info("Cycling #{cycle.length} of #{@changed_processes.length} changed microservices...")
|
|
297
|
+
shutdown_processes(cycle)
|
|
278
298
|
break if @shutdown
|
|
279
299
|
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
cycle_names.each do |name|
|
|
301
|
+
@changed_processes[name].start
|
|
302
|
+
@changed_processes.delete(name)
|
|
303
|
+
end
|
|
282
304
|
end
|
|
283
305
|
end
|
|
284
306
|
end
|
|
@@ -295,8 +317,11 @@ module OpenC3
|
|
|
295
317
|
|
|
296
318
|
def respawn_dead
|
|
297
319
|
@mutex.synchronize do
|
|
298
|
-
@processes.each do |
|
|
320
|
+
@processes.each do |name, p|
|
|
299
321
|
break if @shutdown
|
|
322
|
+
# Skip processes still queued by the per-cycle start limit; they
|
|
323
|
+
# haven't been started yet so they aren't "dead" to be respawned.
|
|
324
|
+
next if @new_processes[name]
|
|
300
325
|
p.output_increment
|
|
301
326
|
unless p.alive?
|
|
302
327
|
# Respawn process
|
|
@@ -437,12 +437,25 @@ module OpenC3
|
|
|
437
437
|
if packet.id_items.length > 0
|
|
438
438
|
key = []
|
|
439
439
|
id_signature = ""
|
|
440
|
-
# Accessor class
|
|
441
|
-
# with different accessors
|
|
442
|
-
#
|
|
440
|
+
# Accessor class AND args are part of the signature so packets in the
|
|
441
|
+
# same target with different accessors -- or the same accessor class
|
|
442
|
+
# configured with different args -- trigger unique_id_mode. Different
|
|
443
|
+
# accessors (or same accessor, different args) decode the buffer
|
|
444
|
+
# differently, so the shared hash-lookup path is unsafe.
|
|
443
445
|
packet.id_items.each do |item|
|
|
444
446
|
key << item.id_value
|
|
445
|
-
id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}"
|
|
447
|
+
id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}__#{packet.accessor.args.inspect}"
|
|
448
|
+
# STRUCTURE-derived id_items are decoded by the parent's structure
|
|
449
|
+
# accessor (see Accessor#read_item), so include that accessor's class
|
|
450
|
+
# and args in the signature too.
|
|
451
|
+
if item.parent_item
|
|
452
|
+
parent = packet.get_item(item.parent_item)
|
|
453
|
+
structure = parent.structure
|
|
454
|
+
if structure
|
|
455
|
+
structure_accessor = structure.accessor
|
|
456
|
+
id_signature << "__#{structure_accessor.class.to_s}__#{structure_accessor.args.inspect}"
|
|
457
|
+
end
|
|
458
|
+
end
|
|
446
459
|
end
|
|
447
460
|
target_id_value_hash[key] = packet
|
|
448
461
|
target_id_signature = id_signature_hash[packet.target_name]
|