openc3 7.0.0.pre.rc2 → 7.0.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 +13 -4
- data/bin/pipinstall +6 -7
- data/bin/pipuninstall +3 -5
- data/data/config/interface_modifiers.yaml +1 -1
- data/data/config/item_modifiers.yaml +18 -6
- data/data/config/telemetry.yaml +1 -1
- data/data/config/widgets.yaml +10 -0
- data/lib/openc3/accessors/json_accessor.rb +1 -1
- data/lib/openc3/api/cmd_api.rb +2 -0
- data/lib/openc3/api/settings_api.rb +2 -0
- data/lib/openc3/api/tlm_api.rb +3 -3
- data/lib/openc3/config/config_parser.rb +4 -4
- data/lib/openc3/conversions/conversion.rb +3 -3
- data/lib/openc3/core_ext/faraday.rb +4 -0
- data/lib/openc3/logs/log_writer.rb +24 -6
- data/lib/openc3/logs/packet_log_writer.rb +1 -4
- data/lib/openc3/logs/stream_log_pair.rb +11 -4
- data/lib/openc3/logs/text_log_writer.rb +1 -4
- data/lib/openc3/microservices/interface_microservice.rb +8 -2
- data/lib/openc3/microservices/log_microservice.rb +7 -2
- data/lib/openc3/microservices/microservice.rb +10 -4
- data/lib/openc3/microservices/queue_microservice.rb +9 -2
- data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
- data/lib/openc3/microservices/text_log_microservice.rb +4 -1
- data/lib/openc3/migrations/20241208080000_no_critical_cmd.rb +1 -1
- data/lib/openc3/migrations/20250402000000_periodic_only_default.rb +1 -1
- data/lib/openc3/migrations/20260203000000_remove_store_id.rb +28 -0
- data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +29 -1
- data/lib/openc3/models/activity_model.rb +41 -9
- data/lib/openc3/models/auth_model.rb +54 -19
- data/lib/openc3/models/cvt_model.rb +2 -265
- data/lib/openc3/models/model.rb +16 -0
- data/lib/openc3/models/plugin_model.rb +18 -12
- data/lib/openc3/models/plugin_store_model.rb +1 -1
- data/lib/openc3/models/python_package_model.rb +2 -2
- data/lib/openc3/models/queue_model.rb +5 -3
- data/lib/openc3/models/script_engine_model.rb +1 -1
- data/lib/openc3/models/target_model.rb +75 -42
- data/lib/openc3/models/tool_config_model.rb +12 -0
- data/lib/openc3/models/tool_model.rb +18 -5
- data/lib/openc3/models/trigger_model.rb +1 -1
- data/lib/openc3/models/widget_model.rb +2 -9
- data/lib/openc3/operators/operator.rb +9 -7
- data/lib/openc3/packets/json_packet.rb +2 -0
- data/lib/openc3/packets/packet.rb +1 -0
- data/lib/openc3/packets/packet_config.rb +28 -12
- data/lib/openc3/script/calendar.rb +8 -0
- data/lib/openc3/script/script.rb +19 -0
- data/lib/openc3/script/storage.rb +6 -6
- data/lib/openc3/script/web_socket_api.rb +1 -1
- data/lib/openc3/system/system.rb +6 -6
- data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
- data/lib/openc3/top_level.rb +15 -63
- data/lib/openc3/topics/command_topic.rb +1 -0
- data/lib/openc3/topics/limits_event_topic.rb +1 -1
- data/lib/openc3/utilities/authentication.rb +46 -7
- data/lib/openc3/utilities/authorization.rb +8 -1
- data/lib/openc3/utilities/aws_bucket.rb +2 -3
- data/lib/openc3/utilities/bucket_utilities.rb +3 -1
- data/lib/openc3/utilities/cli_generator.rb +7 -0
- data/lib/openc3/utilities/cmd_log.rb +1 -1
- data/lib/openc3/utilities/local_mode.rb +3 -0
- data/lib/openc3/utilities/process_manager.rb +1 -1
- data/lib/openc3/utilities/python_proxy.rb +11 -4
- data/lib/openc3/utilities/questdb_client.rb +764 -2
- data/lib/openc3/utilities/running_script.rb +25 -7
- data/lib/openc3/utilities/script.rb +452 -0
- data/lib/openc3/utilities/secrets.rb +1 -1
- data/lib/openc3/version.rb +5 -5
- data/templates/conversion/conversion.py +0 -8
- data/templates/conversion/conversion.rb +0 -11
- 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 +19 -19
- data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +0 -23
- data/lib/openc3/migrations/20251213120000_reinstall_plugins.rb +0 -45
|
@@ -16,6 +16,120 @@ require 'openc3/microservices/cleanup_microservice'
|
|
|
16
16
|
|
|
17
17
|
module OpenC3
|
|
18
18
|
class ScopeCleanupMicroservice < CleanupMicroservice
|
|
19
|
+
TSDB_HEALTH_QUERY =
|
|
20
|
+
"SELECT
|
|
21
|
+
table_name,
|
|
22
|
+
table_row_count,
|
|
23
|
+
wal_pending_row_count,
|
|
24
|
+
CASE
|
|
25
|
+
WHEN table_suspended THEN 'SUSPENDED'
|
|
26
|
+
WHEN table_memory_pressure_level = 2 THEN 'BACKOFF'
|
|
27
|
+
WHEN table_memory_pressure_level = 1 THEN 'PRESSURE'
|
|
28
|
+
ELSE 'OK'
|
|
29
|
+
END AS status,
|
|
30
|
+
wal_txn - table_txn AS lag_txns,
|
|
31
|
+
table_write_amp_p50 AS write_amp,
|
|
32
|
+
table_merge_rate_p99 AS slowest_merge
|
|
33
|
+
FROM tables()
|
|
34
|
+
WHERE walEnabled
|
|
35
|
+
ORDER BY
|
|
36
|
+
table_suspended DESC,
|
|
37
|
+
table_memory_pressure_level DESC,
|
|
38
|
+
wal_pending_row_count DESC;"
|
|
39
|
+
|
|
40
|
+
GROWTH_NUM_SAMPLE_PERIODS = 4
|
|
41
|
+
|
|
42
|
+
def initialize(*args)
|
|
43
|
+
super(*args)
|
|
44
|
+
@run_time = nil
|
|
45
|
+
@cleanup_poll_time = nil
|
|
46
|
+
@delta_time = 0.0
|
|
47
|
+
@wal_pending_row_count = {}
|
|
48
|
+
@lag_txns = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cleanup(areas, bucket)
|
|
52
|
+
current_time = Time.now
|
|
53
|
+
if @run_time
|
|
54
|
+
delta = current_time - @run_time
|
|
55
|
+
if delta > 0.0
|
|
56
|
+
@delta_time += delta
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
@run_time = current_time
|
|
60
|
+
if @delta_time > @cleanup_poll_time
|
|
61
|
+
@delta_time = 0.0
|
|
62
|
+
super(areas, bucket)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Always check TSDB health
|
|
66
|
+
if @scope == 'DEFAULT'
|
|
67
|
+
begin
|
|
68
|
+
conn = OpenC3::QuestDBClient.connection
|
|
69
|
+
result = conn.exec(TSDB_HEALTH_QUERY)
|
|
70
|
+
columns = result.fields
|
|
71
|
+
rows = result.values
|
|
72
|
+
|
|
73
|
+
table_name_column = columns.index("table_name")
|
|
74
|
+
wal_pending_row_count_column = columns.index("wal_pending_row_count")
|
|
75
|
+
status_column = columns.index("status")
|
|
76
|
+
lag_txns_column = columns.index("lag_txns")
|
|
77
|
+
|
|
78
|
+
rows.each do |values|
|
|
79
|
+
table_name = values[table_name_column]
|
|
80
|
+
wal_pending_row_count = values[wal_pending_row_count_column].to_i
|
|
81
|
+
status = values[status_column]
|
|
82
|
+
lag_txns = values[lag_txns_column].to_i
|
|
83
|
+
|
|
84
|
+
if status != 'OK'
|
|
85
|
+
@logger.error("QuestDB: #{table_name} in bad state: #{status}")
|
|
86
|
+
|
|
87
|
+
if status == 'SUSPENDED'
|
|
88
|
+
# Try to automatically unsuspend
|
|
89
|
+
@logger.info("QuestDB: Attempting to unsuspend: #{table_name}")
|
|
90
|
+
conn.exec("ALTER TABLE #{table_name} RESUME WAL;")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@wal_pending_row_count[table_name] ||= []
|
|
95
|
+
@wal_pending_row_count[table_name] << wal_pending_row_count
|
|
96
|
+
@lag_txns[table_name] ||= []
|
|
97
|
+
@lag_txns[table_name] << lag_txns
|
|
98
|
+
|
|
99
|
+
if @wal_pending_row_count[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
|
|
100
|
+
if detect_growth(@wal_pending_row_count[table_name], GROWTH_NUM_SAMPLE_PERIODS)
|
|
101
|
+
# Crossed threshold of sample periods of growth
|
|
102
|
+
@logger.error("QuestDB: #{table_name} has growing wal_pending_row_count: #{wal_pending_row_count}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
|
|
106
|
+
@wal_pending_row_count[table_name] = @wal_pending_row_count[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if @lag_txns[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
|
|
110
|
+
if detect_growth(@lag_txns[table_name], GROWTH_NUM_SAMPLE_PERIODS)
|
|
111
|
+
# Crossed threshold of sample periods of growth
|
|
112
|
+
@logger.error("QuestDB: #{table_name} has growing lag_txns: #{lag_txns}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
|
|
116
|
+
@lag_txns[table_name] = @lag_txns[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
rescue => e
|
|
120
|
+
OpenC3::QuestDBClient.disconnect
|
|
121
|
+
@logger.error("QuestDB Error: #{e.formatted}")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def detect_growth(array, num_samples)
|
|
127
|
+
num_samples.times do |index|
|
|
128
|
+
return false if array[index + 1] <= array[index]
|
|
129
|
+
end
|
|
130
|
+
return true
|
|
131
|
+
end
|
|
132
|
+
|
|
19
133
|
def get_areas_and_poll_time
|
|
20
134
|
scope = ScopeModel.get_model(name: @scope)
|
|
21
135
|
areas = [
|
|
@@ -28,7 +142,8 @@ module OpenC3
|
|
|
28
142
|
areas << ["NOSCOPE/tool_logs/sr", scope.tool_log_retain_time]
|
|
29
143
|
end
|
|
30
144
|
|
|
31
|
-
|
|
145
|
+
@cleanup_poll_time = scope.cleanup_poll_time
|
|
146
|
+
return areas, 60 # Run every 1 minute for TSDB checks
|
|
32
147
|
end
|
|
33
148
|
end
|
|
34
149
|
end
|
|
@@ -98,13 +98,16 @@ module OpenC3
|
|
|
98
98
|
def shutdown
|
|
99
99
|
# Make sure all the existing logs are properly closed down
|
|
100
100
|
threads = []
|
|
101
|
-
@tlws.each do |
|
|
101
|
+
@tlws.each do |_topic, tlw|
|
|
102
102
|
threads.concat(tlw.shutdown)
|
|
103
103
|
end
|
|
104
104
|
# Wait for all the logging threads to move files to buckets
|
|
105
105
|
threads.flatten.compact.each do |thread|
|
|
106
106
|
thread.join
|
|
107
107
|
end
|
|
108
|
+
@tlws.each do |_topic, tlw|
|
|
109
|
+
tlw.cleanup
|
|
110
|
+
end
|
|
108
111
|
super()
|
|
109
112
|
end
|
|
110
113
|
end
|
|
@@ -13,7 +13,7 @@ module OpenC3
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def self.run
|
|
16
|
-
ScopeModel.get_all_models(scope: nil).each do |scope,
|
|
16
|
+
ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
|
|
17
17
|
model = MicroserviceModel.get_model(name: "#{scope}__CRITICALCMD__#{scope}", scope: scope)
|
|
18
18
|
if BASE # Only remove the critical command model if we're not enterprise
|
|
19
19
|
model.destroy if model
|
|
@@ -5,7 +5,7 @@ require 'openc3/models/microservice_model'
|
|
|
5
5
|
module OpenC3
|
|
6
6
|
class PeriodicOnlyDefault < Migration
|
|
7
7
|
def self.run
|
|
8
|
-
ScopeModel.get_all_models(scope: nil).each do |scope,
|
|
8
|
+
ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
|
|
9
9
|
next if scope == 'DEFAULT'
|
|
10
10
|
model = MicroserviceModel.get_model(name: "#{scope}__SCOPEMULTI__#{scope}", scope: scope)
|
|
11
11
|
if model
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require 'openc3/utilities/migration'
|
|
2
|
+
require 'openc3/models/scope_model'
|
|
3
|
+
require 'openc3/models/plugin_model'
|
|
4
|
+
|
|
5
|
+
module OpenC3
|
|
6
|
+
# Removes the store_id property from plugin models. It got renamed to
|
|
7
|
+
# store_plugin_id in PR #2858 but that also depends on having
|
|
8
|
+
# store_version_id, which didn't exist prior to this version and can't
|
|
9
|
+
# be determined without some introspection on the plugin and querying
|
|
10
|
+
# the app store (online). When store_plugin_id is unset, COSMOS treats
|
|
11
|
+
# the plugin like it was installed from a gem file.
|
|
12
|
+
class RemoveStoreId < Migration
|
|
13
|
+
def self.run
|
|
14
|
+
ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
|
|
15
|
+
plugin_models = PluginModel.all(scope: scope)
|
|
16
|
+
plugin_models.each do |_name, plugin_model|
|
|
17
|
+
plugin_model.delete("store_id")
|
|
18
|
+
model = PluginModel.from_json(plugin_model, scope: scope)
|
|
19
|
+
model.update()
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless ENV['OPENC3_NO_MIGRATE']
|
|
27
|
+
OpenC3::RemoveStoreId.run
|
|
28
|
+
end
|
|
@@ -10,17 +10,21 @@
|
|
|
10
10
|
# if purchased from OpenC3, Inc.
|
|
11
11
|
|
|
12
12
|
require 'openc3/utilities/migration'
|
|
13
|
+
require 'openc3/utilities/bucket'
|
|
13
14
|
require 'openc3/models/scope_model'
|
|
14
15
|
require 'openc3/models/target_model'
|
|
15
16
|
require 'openc3/models/microservice_model'
|
|
17
|
+
require 'openc3/models/plugin_model'
|
|
16
18
|
|
|
17
19
|
module OpenC3
|
|
18
20
|
class RemoveDecomLogSettings < Migration
|
|
19
21
|
def self.run
|
|
20
|
-
ScopeModel.get_all_models(scope: nil).each do |scope,
|
|
22
|
+
ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
|
|
21
23
|
target_models = TargetModel.all(scope: scope)
|
|
22
24
|
target_models.each do |name, target_model|
|
|
23
25
|
# Remove deprecated decom log settings from target model
|
|
26
|
+
target_model.delete("cmd_unique_id_mode")
|
|
27
|
+
target_model.delete("tlm_unique_id_mode")
|
|
24
28
|
target_model.delete("cmd_decom_log_cycle_time")
|
|
25
29
|
target_model.delete("cmd_decom_log_cycle_size")
|
|
26
30
|
target_model.delete("cmd_decom_log_retain_time")
|
|
@@ -51,6 +55,30 @@ module OpenC3
|
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
58
|
+
|
|
59
|
+
# Reinstall all plugins to regenerate microservice configs with correct settings
|
|
60
|
+
# This must happen after removing deprecated keys above
|
|
61
|
+
client = Bucket.getClient()
|
|
62
|
+
unless client.exist?(ENV['OPENC3_CONFIG_BUCKET']) && client.exist?(ENV['OPENC3_TOOLS_BUCKET'])
|
|
63
|
+
Logger.info("Skipping plugin reinstall - buckets do not exist yet (fresh install or new storage backend)")
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
|
|
68
|
+
plugins = PluginModel.all(scope: scope)
|
|
69
|
+
plugins.each do |plugin_name, plugin_data|
|
|
70
|
+
begin
|
|
71
|
+
Logger.info("Reinstalling plugin #{plugin_name} in scope #{scope}")
|
|
72
|
+
plugin_model = PluginModel.from_json(plugin_data, scope: scope)
|
|
73
|
+
plugin_model.undeploy
|
|
74
|
+
plugin_model.restore
|
|
75
|
+
Logger.info("Successfully reinstalled plugin #{plugin_name} in scope #{scope}")
|
|
76
|
+
rescue Exception => e
|
|
77
|
+
Logger.error("Error reinstalling plugin #{plugin_name} in scope #{scope}: #{e.formatted}")
|
|
78
|
+
# Continue with other plugins even if one fails
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
54
82
|
end
|
|
55
83
|
end
|
|
56
84
|
end
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
# https://www.rubydoc.info/gems/redis/Redis/Commands/SortedSets
|
|
19
19
|
|
|
20
20
|
require 'openc3/models/model'
|
|
21
|
+
require 'openc3/models/timeline_model'
|
|
21
22
|
require 'openc3/topics/timeline_topic'
|
|
22
23
|
require 'securerandom'
|
|
23
24
|
|
|
@@ -28,6 +29,11 @@ module OpenC3
|
|
|
28
29
|
|
|
29
30
|
class ActivityModel < Model
|
|
30
31
|
MAX_DURATION = Time::SEC_PER_DAY
|
|
32
|
+
# Grace window (in seconds) to allow creating activities slightly in the past.
|
|
33
|
+
# This handles race conditions where real-time activity notifications arrive
|
|
34
|
+
# after the start time has already passed (e.g. from external systems).
|
|
35
|
+
# This is consistent with the -15 second window in the timeline microservice.
|
|
36
|
+
START_GRACE_SECONDS = 15
|
|
31
37
|
PRIMARY_KEY = '__openc3_timelines'.freeze # MUST be equal to `TimelineModel::PRIMARY_KEY` minus the leading __
|
|
32
38
|
# See run_activity(activity) in openc3/lib/openc3/microservices/timeline_microservice.rb
|
|
33
39
|
VALID_KINDS = %w(command script reserve expire)
|
|
@@ -212,7 +218,7 @@ module OpenC3
|
|
|
212
218
|
end
|
|
213
219
|
|
|
214
220
|
# validate the input to the rules we have created for timelines.
|
|
215
|
-
# - A task's start MUST NOT be in the past.
|
|
221
|
+
# - A task's start MUST NOT be more than START_GRACE_SECONDS in the past.
|
|
216
222
|
# - A task's start MUST be before the stop.
|
|
217
223
|
# - A task CAN NOT be longer than MAX_DURATION (86400) in seconds.
|
|
218
224
|
# - A task MUST have a kind.
|
|
@@ -230,8 +236,8 @@ module OpenC3
|
|
|
230
236
|
rescue NoMethodError
|
|
231
237
|
raise ActivityInputError.new "start and stop must be seconds: #{start}, #{stop}"
|
|
232
238
|
end
|
|
233
|
-
if now_f >= start and kind != 'expire'
|
|
234
|
-
raise ActivityInputError.new "activity must be in the
|
|
239
|
+
if now_f >= start + START_GRACE_SECONDS and kind != 'expire'
|
|
240
|
+
raise ActivityInputError.new "activity must not be more than #{START_GRACE_SECONDS} seconds in the past, current_time: #{now_f} vs #{start}"
|
|
235
241
|
elsif duration > MAX_DURATION and kind != 'expire'
|
|
236
242
|
raise ActivityInputError.new "activity can not be longer than #{MAX_DURATION} seconds"
|
|
237
243
|
elsif duration <= 0
|
|
@@ -261,7 +267,13 @@ module OpenC3
|
|
|
261
267
|
|
|
262
268
|
# Update the Redis hash at primary_key and set the score equal to the start Epoch time
|
|
263
269
|
# the member is set to the JSON generated via calling as_json
|
|
264
|
-
def create(overlap: true)
|
|
270
|
+
def create(overlap: true, username: nil)
|
|
271
|
+
# Validate that the timeline exists in this scope before creating activities.
|
|
272
|
+
# Activities must be attached to an existing timeline within the same scope.
|
|
273
|
+
unless TimelineModel.get(name: @name, scope: @scope)
|
|
274
|
+
raise ActivityError.new "timeline '#{@name}' does not exist in scope '#{@scope}'"
|
|
275
|
+
end
|
|
276
|
+
|
|
265
277
|
if @recurring['end'] and @recurring['frequency'] and @recurring['span']
|
|
266
278
|
# First validate the initial recurring activity ... all others are just offsets
|
|
267
279
|
validate_input(start: @start, stop: @stop, kind: @kind, data: @data)
|
|
@@ -290,7 +302,7 @@ module OpenC3
|
|
|
290
302
|
|
|
291
303
|
# Update @updated_at and add an event assuming it all completes ok
|
|
292
304
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
293
|
-
add_event(status: 'created')
|
|
305
|
+
add_event(status: 'created', username: username)
|
|
294
306
|
|
|
295
307
|
Store.multi do |multi|
|
|
296
308
|
(@start..@recurring['end']).step(recurrence).each do |start_time|
|
|
@@ -326,7 +338,7 @@ module OpenC3
|
|
|
326
338
|
end
|
|
327
339
|
end
|
|
328
340
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
329
|
-
add_event(status: 'created')
|
|
341
|
+
add_event(status: 'created', username: username)
|
|
330
342
|
Store.zadd(@primary_key, @start, JSON.generate(self.as_json, allow_nan: true))
|
|
331
343
|
notify(kind: 'created')
|
|
332
344
|
end
|
|
@@ -335,7 +347,7 @@ module OpenC3
|
|
|
335
347
|
# Update the Redis hash at primary_key and remove the current activity at the current score
|
|
336
348
|
# and update the score to the new score equal to the start Epoch time this uses a multi
|
|
337
349
|
# to execute both the remove and create.
|
|
338
|
-
def update(start:, stop:, kind:, data:, overlap: true)
|
|
350
|
+
def update(start:, stop:, kind:, data:, overlap: true, username: nil)
|
|
339
351
|
array = Store.zrangebyscore(@primary_key, @start, @start)
|
|
340
352
|
if array.length == 0
|
|
341
353
|
raise ActivityError.new "failed to find activity at: #{@start}"
|
|
@@ -350,10 +362,22 @@ module OpenC3
|
|
|
350
362
|
raise ActivityOverlapError.new "failed to update #{old_start}, no activities can overlap, collision: #{collision}"
|
|
351
363
|
end
|
|
352
364
|
end
|
|
365
|
+
|
|
366
|
+
# Compute changeset for audit trail before applying changes
|
|
367
|
+
changes = {}
|
|
368
|
+
diff_field(changes, 'start', @start, start)
|
|
369
|
+
diff_field(changes, 'stop', @stop, stop)
|
|
370
|
+
diff_field(changes, 'kind', @kind, kind)
|
|
371
|
+
old_data = @data.reject { |k, _| k == 'username' }
|
|
372
|
+
new_data = data.reject { |k, _| k == 'username' }
|
|
373
|
+
(old_data.keys | new_data.keys).each do |key|
|
|
374
|
+
diff_field(changes, "data.#{key}", old_data[key], new_data[key])
|
|
375
|
+
end
|
|
376
|
+
|
|
353
377
|
set_input(start: start, stop: stop, kind: kind, data: data, events: @events)
|
|
354
378
|
@updated_at = Time.now.to_nsec_from_epoch
|
|
355
379
|
|
|
356
|
-
add_event(status: 'updated')
|
|
380
|
+
add_event(status: 'updated', username: username, changes: changes.empty? ? nil : changes)
|
|
357
381
|
json = Store.zrangebyscore(@primary_key, old_start, old_start)
|
|
358
382
|
parsed = json.map { |value| JSON.parse(value, allow_nan: true, create_additions: true) }
|
|
359
383
|
parsed.each_with_index do |value, index|
|
|
@@ -397,14 +421,22 @@ module OpenC3
|
|
|
397
421
|
|
|
398
422
|
# add_event will make an event. This will NOT save the object to the redis database
|
|
399
423
|
# @param [String] status - the event status such as "queued" or "updated" or "created"
|
|
400
|
-
|
|
424
|
+
# @param [String] username - optional username of who performed the action
|
|
425
|
+
# @param [Hash] changes - optional hash describing what fields changed (for audit)
|
|
426
|
+
def add_event(status:, username: nil, changes: nil)
|
|
401
427
|
event = {
|
|
402
428
|
'time' => Time.now.to_i,
|
|
403
429
|
'event' => status
|
|
404
430
|
}
|
|
431
|
+
event['username'] = username if username
|
|
432
|
+
event['changes'] = changes if changes
|
|
405
433
|
@events << event
|
|
406
434
|
end
|
|
407
435
|
|
|
436
|
+
def diff_field(changes, field, old_val, new_val)
|
|
437
|
+
changes[field] = {'old' => old_val, 'new' => new_val} unless old_val == new_val
|
|
438
|
+
end
|
|
439
|
+
|
|
408
440
|
# update the redis stream / timeline topic that something has changed
|
|
409
441
|
def notify(kind:, extra: nil)
|
|
410
442
|
notification = {
|
|
@@ -41,6 +41,9 @@ module OpenC3
|
|
|
41
41
|
|
|
42
42
|
MIN_PASSWORD_LENGTH = 8
|
|
43
43
|
|
|
44
|
+
SESSION_PREFIX = "ses_"
|
|
45
|
+
OTP_PREFIX = "otp_"
|
|
46
|
+
|
|
44
47
|
def self.set?(key = PRIMARY_KEY)
|
|
45
48
|
Store.exists(key) == 1
|
|
46
49
|
end
|
|
@@ -58,36 +61,49 @@ module OpenC3
|
|
|
58
61
|
|
|
59
62
|
return false if service_only
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
mode = no_password ? :token : :any
|
|
65
|
+
return verify_no_service(token, mode: mode)
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
# Checks whether the provided token is a valid user password or session token.
|
|
65
69
|
# @param token [String] the plaintext password or session token to check (required)
|
|
66
|
-
# @param
|
|
70
|
+
# @param mode [String] optionally restrict verification to just the password or token. Valid values: :password, :token, or :any (default :token)
|
|
67
71
|
# @return [Boolean] whether the provided password/token is valid
|
|
68
|
-
def self.verify_no_service(token,
|
|
72
|
+
def self.verify_no_service(token, mode: :token)
|
|
73
|
+
modes = [:password, :token, :any]
|
|
74
|
+
raise ArgumentError, "Invalid mode '#{mode}': must be one of #{modes}" unless modes.include?(mode)
|
|
75
|
+
|
|
69
76
|
return false if token.nil? or token.empty?
|
|
70
77
|
|
|
71
78
|
# Check cached session tokens and password hash
|
|
72
79
|
time = Time.now
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
unless mode == :password
|
|
81
|
+
if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
|
|
82
|
+
terminate_otp(token)
|
|
83
|
+
return true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check stored session tokens
|
|
87
|
+
@@session_cache = Store.hgetall(SESSIONS_KEY)
|
|
88
|
+
@@session_cache_time = time
|
|
89
|
+
if @@session_cache[token]
|
|
90
|
+
terminate_otp(token)
|
|
91
|
+
return true
|
|
92
|
+
end
|
|
76
93
|
end
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@@session_cache_time = time
|
|
81
|
-
return true if @@session_cache[token]
|
|
95
|
+
unless mode == :token
|
|
96
|
+
return true if @@pw_hash_cache and (time - @@pw_hash_cache_time) < PW_HASH_CACHE_TIMEOUT and Argon2::Password.verify_password(token, @@pw_hash_cache)
|
|
82
97
|
|
|
83
|
-
|
|
98
|
+
# Check stored password hash
|
|
99
|
+
pw_hash = Store.get(PRIMARY_KEY)
|
|
100
|
+
raise "invalid password hash" if pw_hash.nil? || !pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
|
|
101
|
+
@@pw_hash_cache = pw_hash
|
|
102
|
+
@@pw_hash_cache_time = time
|
|
103
|
+
return true if Argon2::Password.verify_password(token, @@pw_hash_cache)
|
|
104
|
+
end
|
|
84
105
|
|
|
85
|
-
|
|
86
|
-
pw_hash = Store.get(PRIMARY_KEY)
|
|
87
|
-
raise "invalid password hash" unless pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
|
|
88
|
-
@@pw_hash_cache = pw_hash
|
|
89
|
-
@@pw_hash_cache_time = time
|
|
90
|
-
return Argon2::Password.verify_password(token, @@pw_hash_cache)
|
|
106
|
+
return false
|
|
91
107
|
end
|
|
92
108
|
|
|
93
109
|
def self.set(password, old_password, key = PRIMARY_KEY)
|
|
@@ -96,17 +112,25 @@ module OpenC3
|
|
|
96
112
|
|
|
97
113
|
if set?(key)
|
|
98
114
|
raise "old_password must not be nil or empty" if old_password.nil? or old_password.empty?
|
|
99
|
-
raise "old_password incorrect" unless verify_no_service(old_password,
|
|
115
|
+
raise "old_password incorrect" unless verify_no_service(old_password, mode: :password)
|
|
100
116
|
end
|
|
101
117
|
pw_hash = Argon2::Password.create(password, profile: ARGON2_PROFILE)
|
|
102
118
|
Store.set(key, pw_hash)
|
|
103
119
|
@@pw_hash_cache = nil
|
|
104
120
|
@@pw_hash_cache_time = nil
|
|
121
|
+
logout
|
|
105
122
|
end
|
|
106
123
|
|
|
107
124
|
# Creates a new session token. DO NOT CALL BEFORE VERIFYING.
|
|
108
|
-
|
|
125
|
+
# @param otp [Boolean] whether to create a one-time use token (default: false)
|
|
126
|
+
# @return [String] the new session token
|
|
127
|
+
def self.generate_session(otp: false)
|
|
109
128
|
token = SecureRandom.urlsafe_base64(nil, false)
|
|
129
|
+
if otp
|
|
130
|
+
token = OTP_PREFIX + token
|
|
131
|
+
else
|
|
132
|
+
token = SESSION_PREFIX + token
|
|
133
|
+
end
|
|
110
134
|
Store.hset(SESSIONS_KEY, token, Time.now.iso8601)
|
|
111
135
|
return token
|
|
112
136
|
end
|
|
@@ -117,5 +141,16 @@ module OpenC3
|
|
|
117
141
|
@@session_cache = nil
|
|
118
142
|
@@session_cache_time = nil
|
|
119
143
|
end
|
|
144
|
+
|
|
145
|
+
# Terminates the given session token.
|
|
146
|
+
def self.terminate(token)
|
|
147
|
+
Store.hdel(SESSIONS_KEY, token)
|
|
148
|
+
@@session_cache.delete(token) if @@session_cache
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Terminates the session if the token is an OTP.
|
|
152
|
+
def self.terminate_otp(token)
|
|
153
|
+
terminate(token) if token.start_with?(OTP_PREFIX)
|
|
154
|
+
end
|
|
120
155
|
end
|
|
121
156
|
end
|