-
Notifications
You must be signed in to change notification settings - Fork 72
Expand file tree
/
Copy pathopenc3cli
More file actions
executable file
·1362 lines (1274 loc) · 47.3 KB
/
openc3cli
File metadata and controls
executable file
·1362 lines (1274 loc) · 47.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env ruby
# encoding: ascii-8bit
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# Modified by OpenC3, Inc.
# All changes Copyright 2025, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
# This file will handle OpenC3 tasks such as instantiating a new project
require 'openc3'
require 'openc3/bridge/bridge'
require 'openc3/models/gem_model'
require 'openc3/models/migration_model'
require 'openc3/models/plugin_model'
require 'openc3/models/python_package_model'
require 'openc3/models/queue_model'
require 'openc3/models/scope_model'
require 'openc3/models/tool_model'
require 'openc3/packets/packet_config'
require 'openc3/utilities/bucket'
require 'openc3/utilities/cli_generator'
require 'openc3/utilities/local_mode'
require 'ostruct'
require 'optparse'
require 'openc3/utilities/zip'
require 'fileutils'
require 'find'
require 'json'
require 'redis'
require 'erb'
require 'irb'
require 'irb/completion'
require 'digest'
require 'argon2'
$redis_url = "redis://#{ENV['OPENC3_REDIS_HOSTNAME']}:#{ENV['OPENC3_REDIS_PORT']}"
ERROR_CODE = 1
CLI_SCRIPT_ACTIONS = %w(help list run spawn init)
$script_interrupt_text = ''
trap('INT') do
abort("Interrupted at console; exiting.#{$script_interrupt_text}")
end
# Prints the usage text for the openc3cli executable
def print_usage
puts "Usage:"
puts " cli help # Displays this information"
puts " cli rake # Runs rake in the local directory"
puts " cli irb # Runs irb in the local directory"
puts " cli script # Interact with scripts. Run with --help for more info."
puts " cli validate /PATH/FILENAME.gem SCOPE variables.json # Validate a COSMOS plugin gem file"
puts " cli load /PATH/FILENAME.gem SCOPE plugin_hash.json # Loads a COSMOS plugin gem file"
puts " OPTIONS: --variables lets you pass a path to a JSON file containing your plugin's variables"
puts " cli list <SCOPE> # Lists installed plugins, SCOPE is DEFAULT if not given"
puts " cli generate TYPE OPTIONS # Generate various COSMOS entities"
puts " OPTIONS: --ruby or --python is required to specify the language in the generated code unless OPENC3_LANGUAGE is set"
puts " cli bridge CONFIG_FILENAME # Run COSMOS host bridge"
puts " cli bridgegem gem_name variable1=value1 variable2=value2 # Runs bridge using gem bridge.txt"
puts " cli bridgesetup CONFIG_FILENAME # Create a default config file"
puts " cli pkginstall PKGFILENAME SCOPE # Install loaded package (Ruby gem or python package)"
puts " cli pkguninstall PKGFILENAME SCOPE # Uninstall loaded package (Ruby gem or python package)"
puts " cli rubysloc # DEPRECATED: Please use scc (https://github.com/boyter/scc)"
puts " cli xtce_converter # Convert to and from the XTCE format. Run with --help for more info."
puts " cli cstol_converter # Converts CSTOL files (.prc) to COSMOS. Run with --help for more info."
puts ""
end
def check_environment
hostname = ENV['OPENC3_API_HOSTNAME'] || (ENV['OPENC3_DEVEL'] ? '127.0.0.1' : 'openc3-cosmos-cmd-tlm-api')
begin
Resolv.getaddress(hostname)
rescue Resolv::ResolvError
abort "Unable to resolv api hostname: #{hostname}"
end
if hostname =~ /openc3-cosmos-cmd-tlm-api/
$openc3_in_cluster = true
else
$openc3_in_cluster = false
end
unless $openc3_in_cluster
# Make sure the user has all the required environment variables set
abort "OPENC3_API_HOSTNAME environment variable is required" unless ENV['OPENC3_API_HOSTNAME']
abort "OPENC3_API_PORT environment variable is required" unless ENV['OPENC3_API_PORT']
abort "OPENC3_API_PASSWORD environment variable is required" unless ENV['OPENC3_API_PASSWORD']
end
end
def xtce_converter(args)
options = {}
option_parser = OptionParser.new do |opts|
opts.banner = "Usage: xtce_converter [options] --import input_xtce_filename --output output_dir\n"+
" xtce_converter [options] --plugin /PATH/FILENAME.gem --output output_dir --variables variables.txt"
opts.separator("")
opts.on("-h", "--help", "Show this message") do
puts opts
exit
end
opts.on("-i VALUE", "--import VALUE", "Import the specified .xtce file") do |arg|
options[:import] = arg
end
opts.on("-o", "--output DIRECTORY", "Create files in the directory") do |arg|
options[:output] = arg
end
opts.on("-p", "--plugin PLUGIN", "Export .xtce file(s) from the plugin") do |arg|
options[:plugin] = arg
end
opts.on("-v", "--variables", "Optional variables file to pass to the plugin") do |arg|
options[:variables] = arg
end
end
begin
option_parser.parse!(args)
rescue
abort(option_parser.to_s)
end
if options[:import] && options[:plugin]
puts "xtce_converter options --import and --plugin are mutually exclusive"
abort(option_parser.to_s)
end
ENV['OPENC3_NO_STORE'] = '1' # it can be anything
OpenC3::Logger.stdout = false
OpenC3::Logger.level = OpenC3::Logger::DEBUG
if options[:import] && options[:output]
packet_config = OpenC3::PacketConfig.new
puts "Processing #{options[:import]}..."
packet_config.process_file(options[:import], nil)
puts "Writing COSMOS config files to #{options[:output]}/"
packet_config.to_config(options[:output])
exit(0)
elsif options[:plugin] && options[:output]
begin
variables = nil
variables = JSON.parse(File.read(options[:variables]), allow_nan: true, create_additions: true) if options[:variables]
puts "Installing #{File.basename(options[:plugin])}"
plugin_hash = OpenC3::PluginModel.install_phase1(options[:plugin], existing_variables: variables, scope: 'DEFAULT', validate_only: true)
plugin_hash['variables']['xtce_output'] = options[:output]
OpenC3::PluginModel.install_phase2(plugin_hash, scope: 'DEFAULT', validate_only: true,
gem_file_path: options[:plugin])
result = 0 # bash and Windows consider 0 success
rescue => e
puts "Error: #{e.message}"
puts e.backtrace
result = ERROR_CODE
ensure
exit(result)
end
else
abort(option_parser.to_s)
end
end
# A helper method to make the zip writing recursion work
def write_zip_entries(base_dir, entries, zip_path, io)
io.add(zip_path, base_dir) # Add the directory whether it has entries or not
entries.each do |e|
zip_file_path = File.join(zip_path, e)
disk_file_path = File.join(base_dir, e)
if File.directory? disk_file_path
recursively_deflate_directory(disk_file_path, io, zip_file_path)
else
put_into_archive(disk_file_path, io, zip_file_path)
end
end
end
def recursively_deflate_directory(disk_file_path, io, zip_file_path)
io.add(zip_file_path, disk_file_path)
write_zip_entries(disk_file_path, entries, zip_file_path, io)
end
def put_into_archive(disk_file_path, io, zip_file_path)
io.get_output_stream(zip_file_path) do |f|
data = nil
File.open(disk_file_path, 'rb') { |file| data = file.read }
f.write(data)
end
end
def validate_plugin(plugin_file_path, scope:, variables_file: nil)
ENV['OPENC3_NO_STORE'] = '1' # it can be anything
OpenC3::Logger.stdout = false
OpenC3::Logger.level = OpenC3::Logger::DEBUG
scope ||= 'DEFAULT'
variables = nil
variables = JSON.parse(File.read(variables_file), allow_nan: true, create_additions: true) if variables_file
puts "Installing #{File.basename(plugin_file_path)}"
plugin_hash = OpenC3::PluginModel.install_phase1(plugin_file_path, existing_variables: variables, scope: scope, validate_only: true)
OpenC3::PluginModel.install_phase2(plugin_hash, scope: scope, validate_only: true,
gem_file_path: plugin_file_path)
puts "Successfully validated #{File.basename(plugin_file_path)}"
result = 0 # bash and Windows consider 0 success
rescue => e
puts e.message
result = ERROR_CODE
ensure
exit(result)
end
def update_plugin(plugin_file_path, plugin_name, variables: nil, plugin_txt_lines: nil, scope:, existing_plugin_name:, force: false)
new_gem = File.basename(plugin_file_path)
old_gem = existing_plugin_name.split("__")[0]
puts "Updating existing plugin: #{existing_plugin_name} with #{File.basename(plugin_file_path)}"
plugin_model = OpenC3::PluginModel.get_model(name: existing_plugin_name, scope: scope)
begin
# Only update if something has changed
if force or (new_gem != old_gem) or (variables and variables != plugin_model.variables) or (plugin_txt_lines and plugin_txt_lines != plugin_model.plugin_txt_lines)
puts "Gem version change detected - New: #{new_gem}, Old: #{old_gem}" if new_gem != old_gem
if variables and variables != plugin_model.variables
pp_variables = ""
PP.pp(variables, pp_variables)
pp_plugin_model_variables = ""
PP.pp(plugin_model.variables, pp_plugin_model_variables)
puts "Variables change detected\nNew:\n#{pp_variables}\nOld:\n#{pp_plugin_model_variables}"
end
puts "plugin.txt change detected\nNew:\n#{plugin_txt_lines.join("\n")}\n\nOld:\n#{plugin_model.plugin_txt_lines.join("\n")}\n" if plugin_txt_lines and plugin_txt_lines != plugin_model.plugin_txt_lines
variables = plugin_model.variables unless variables
plugin_model.destroy
plugin_hash = OpenC3::PluginModel.install_phase1(plugin_file_path, existing_variables: variables, existing_plugin_txt_lines: plugin_txt_lines, process_existing: true, scope: scope)
puts "Updating plugin: #{plugin_file_path}\n#{plugin_hash}"
plugin_hash = OpenC3::PluginModel.install_phase2(plugin_hash, scope: scope)
OpenC3::LocalMode.update_local_plugin(plugin_file_path, plugin_hash, old_plugin_name: plugin_name, scope: scope)
else
puts "No changes detected - Exiting without change"
end
rescue => e
puts e.formatted
if plugin_model.destroyed?
plugin_model.restore
# Local mode files should still be good because restore will now reuse the old name
end
raise e
end
end
def wait_process_complete_internal(process_name, scope:)
STDOUT.flush
state = 'Running'
status = nil
while true
status = OpenC3::ProcessStatusModel.get(name: process_name, scope: scope)
state = status['state']
break if state != 'Running'
sleep(5)
print '.'
STDOUT.flush
end
puts "\nFinished: #{state}"
puts "Output:\n"
puts status['output']
if state == 'Complete'
puts "Success!"
exit 0
else
puts "Failed!"
exit 1
end
end
def wait_process_complete(process_name)
STDOUT.flush
state = 'Running'
status = nil
while state == 'Running'
status = plugin_status(process_name)
state = status['state']
sleep(5)
print '.'
STDOUT.flush
end
puts "\nFinished: #{state}"
puts "Output:\n"
puts status['output']
if state == 'Complete'
puts "Success!"
exit 0
else
puts "Failed!"
exit 1
end
end
# Outputs list of installed plugins
def list_plugins(scope:)
scope ||= 'DEFAULT'
check_environment()
names = []
if $openc3_in_cluster
names = OpenC3::PluginModel.names(scope: scope)
else
require 'openc3/script'
names = plugin_list(scope: scope)
end
names.each do |name|
puts name
end
end
# Loads a plugin into the OpenC3 system
# This code is used from the command line and is the same code that gets called if you
# edit/upgrade or install a new plugin from the Admin interface
#
# Usage: cli load gemfile_path [scope] [plugin_hash_file_path] [force]
#
# With just gemfile_path and/or scope: Will do nothing if any plugin
# with the same gem file already exists
#
# Otherwise will do what the plugin_hash_file says to do
# Plugin hash file must have the exact name of an existing plugin for upgrades and edits
# Otherwise, it will be assumed that the plugin is intentionally being installed for a second
# time
#
# Pass true as the last argument to force install even if a plugin with
# the same version number exists
#
def load_plugin(plugin_file_path, scope:, plugin_hash_file: nil, force: false, variables_file: nil)
scope ||= 'DEFAULT'
check_environment()
if $openc3_in_cluster
# In Cluster
# Only create the scope if it doesn't already exist
unless OpenC3::ScopeModel.names.include?(scope)
begin
puts "Creating scope: #{scope}"
scope_model = OpenC3::ScopeModel.new(name: scope)
scope_model.create
scope_model.deploy(".", {})
rescue => e
abort("Error creating scope: #{scope}: #{e.formatted}")
end
end
begin
existing_variables = JSON.parse(File.read(variables_file)) if variables_file
if plugin_hash_file
# Admin Create / Edit / or Upgrade Plugin
OpenC3::PluginModel.install_phase1(plugin_file_path, existing_variables: existing_variables, scope: scope)
plugin_hash = JSON.parse(File.read(plugin_hash_file), allow_nan: true, create_additions: true)
else
# Init or Command Line openc3cli load with no plugin_hash_file
file_full_name = File.basename(plugin_file_path, ".gem")
file_gem_name = file_full_name.split('-')[0..-2].join('-')
found = false
plugin_names = OpenC3::PluginModel.names(scope: scope)
plugin_names.each do |plugin_name|
gem_name = plugin_name.split("__")[0]
full_name = File.basename(gem_name, ".gem")
gem_name = full_name.split('-')[0..-2].join('-')
if file_gem_name == gem_name
found = true
# Upgrade if version changed else do nothing
if file_full_name != full_name
update_plugin(plugin_file_path, plugin_name, scope: scope, existing_plugin_name: plugin_name, force: force)
else
puts "No version change detected for: #{plugin_name}"
end
end
end
return if found
plugin_hash = OpenC3::PluginModel.install_phase1(plugin_file_path, existing_variables: existing_variables, scope: scope)
end
# Determine if plugin named in plugin_hash exists
existing_plugin_hash = OpenC3::PluginModel.get(name: plugin_hash['name'], scope: scope)
# Existing plugin hash will be present if plugin is being edited or upgraded
# However, a missing existing could also be that a plugin was updated in local mode directly from across installations
# changing the plugin name without really meaning to create a new instance of the plugin
# ie.
# User on machine 1 checks in a changed plugin_instance.json with a different name - There is still only one plugin desired and committed
# User on machine 2 starts up with the new configuration, OpenC3::PluginModel.get will return nil because the exact name is different
# In this case, the plugin should be updated without installing a second instance. analyze_local_mode figures this out.
unless existing_plugin_hash
existing_plugin_hash = OpenC3::LocalMode.analyze_local_mode(plugin_name: plugin_hash['name'], scope: scope)
end
if existing_plugin_hash
# Upgrade or Edit
update_plugin(plugin_file_path, plugin_hash['name'], variables: plugin_hash['variables'], scope: scope,
plugin_txt_lines: plugin_hash['plugin_txt_lines'], existing_plugin_name: existing_plugin_hash['name'], force: force)
else
# New Install
puts "Loading new plugin: #{plugin_file_path}\n#{plugin_hash}"
plugin_hash = OpenC3::PluginModel.install_phase2(plugin_hash, scope: scope)
OpenC3::LocalMode.update_local_plugin(plugin_file_path, plugin_hash, scope: scope)
end
rescue => e
abort("Error installing plugin: #{scope}: #{plugin_file_path}\n#{e.formatted}")
end
else
# Outside Cluster
require 'openc3/script'
if plugin_hash_file
plugin_hash = JSON.parse(File.read(plugin_hash_file), allow_nan: true, create_additions: true)
else
plugin_hash = plugin_install_phase1(plugin_file_path, scope: scope)
end
process_name = plugin_install_phase2(plugin_hash, scope: scope)
print "Installing..."
wait_process_complete(process_name)
end
end
def unload_plugin(plugin_name, scope:)
scope ||= 'DEFAULT'
check_environment()
if $openc3_in_cluster
begin
plugin_model = OpenC3::PluginModel.get_model(name: plugin_name, scope: scope)
plugin_model.destroy
OpenC3::LocalMode.remove_local_plugin(plugin_name, scope: scope)
OpenC3::Logger.info("PluginModel destroyed: #{plugin_name}", scope: scope)
rescue => e
abort("Error uninstalling plugin: #{scope}: #{plugin_name}: #{e.formatted}")
end
else
# Outside Cluster
require 'openc3/script'
process_name = plugin_uninstall(plugin_name, scope: scope)
print "Uninstalling..."
wait_process_complete(process_name)
end
end
def cli_pkg_install(filename, scope:)
scope ||= 'DEFAULT'
check_environment()
if $openc3_in_cluster
if File.extname(filename) == '.gem'
OpenC3::GemModel.install(filename, scope: scope)
else
process_name = OpenC3::PythonPackageModel.install(filename, scope: scope)
print "Installing..."
wait_process_complete_internal(process_name, scope: scope)
end
else
# Outside Cluster
require 'openc3/script'
process_name = package_install(filename, scope: scope)
print "Installing..."
wait_process_complete(process_name)
end
end
def cli_pkg_uninstall(filename, scope:)
scope ||= 'DEFAULT'
check_environment()
if $openc3_in_cluster
if File.extname(filename) == '.rb'
OpenC3::GemModel.destroy(filename)
else
process_name = OpenC3::PythonPackageModel.destroy(filename, scope: scope)
print "Uninstalling..."
wait_process_complete_internal(process_name, scope: scope)
end
else
# Outside Cluster
require 'openc3/script'
process_name = package_uninstall(filename, scope: scope)
if File.extname(filename) == '.rb'
puts "Uninstalled"
else
print "Uninstalling..."
wait_process_complete(process_name)
end
end
end
def get_redis_keys
redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
puts "\n--- COSMOS Redis database keys ---"
cursor = 0
keys = []
loop do
cursor, result = redis.scan(cursor)
keys.concat(result)
cursor = cursor.to_i # cursor is returned as a string
break if cursor == 0
end
keys.uniq!
keys.sort!
keys.select { |item| !item[/^tlm__/] }.each do |key|
puts "#{key}\n #{redis.hkeys(key)}"
rescue Redis::CommandError
begin
# CommandError is raised if you try to hkeys on a stream
puts "Stream: #{key}\n #{redis.xinfo(:stream, key)}"
rescue
puts "Unknown key '#{key}'"
end
end
puts "Packets Defs: #{keys.select { |item| item[/^tlm__/] }}"
end
def run_migrations(folder)
# Determine if this is a brand new installation (no tools installed)
# We don't run migrations on new installations
tools = OpenC3::ToolModel.names(scope: 'DEFAULT')
if tools.length <= 0
puts "Brand new installation detected"
brand_new = true
else
puts "Checking for needed migrations..."
brand_new = false
end
# Run each newly discovered migration unless brand_new
if !folder
folder = "/openc3/lib/openc3/migrations"
entries = Dir.entries(folder).map { |entry| File.join(folder, entry) }
folder = "/openc3-enterprise/lib/openc3-enterprise/migrations"
if File.exist?(folder)
entries.concat(Dir.entries(folder).map { |entry| File.join(folder, entry) })
end
entries = entries.sort() # run in alphabetical order
else
entries = Dir.entries(folder).sort
end
migrations = OpenC3::MigrationModel.all
entries.each do |entry|
name = File.basename(entry)
extension = File.extname(name)
if extension == '.rb' and not migrations[name]
unless brand_new
puts "Running Migration: #{name}"
require entry
end
OpenC3::MigrationModel.new(name: name).create
end
end
if brand_new
puts "All migrations skipped"
else
puts "Migrations complete"
end
end
def run_bridge(filename, params)
variables = {}
params.each do |param|
name, value = param.split('=')
if name and value
variables[name] = value
else
raise "Invalid variable passed to bridgegem (syntax name=value): #{param}"
end
end
OpenC3::Bridge.new(filename, variables)
begin
while true
sleep(1)
end
rescue Interrupt
exit(0)
end
end
def cli_script_monitor(script_id)
ret_code = ERROR_CODE
require 'openc3/script'
OpenC3::RunningScriptWebSocketApi.new(id: script_id) do |api|
while (resp = api.read) do
# see ScriptRunner.vue for types and states
case resp['type']
when 'file'
puts "Filename #{resp['filename']} scope #{resp['scope']}"
when 'line'
fn = resp['filename'].nil? ? '<no file>' : resp['filename']
puts "At [#{fn}:#{resp['line_no']}] state [#{resp['state']}]"
if resp['state'] == 'error' or resp['state'] == 'crashed'
$script_interrupt_text = ''
puts 'script failed'
break
end
when 'output'
puts resp['line']
when 'complete'
$script_interrupt_text = ''
puts 'script complete'
ret_code = 0
break
# These conditions are all handled by the else
# when 'running', 'breakpoint', 'waiting', 'time'
else
puts resp.pretty_inspect
end
end
end
return ret_code
end
def get_env_from_args(args)
# Figure out if there are any optional environment variables
# args[0] is the command, args[1] is the script file
# args[2..-1] are environment variables specified as NAME=VALUE
environ = {}
if args.length > 2
args[2..-1].each do |arg|
name, value = arg.split('=')
if name and value
environ[name] = value
end
end
end
return environ
end
def cli_script_init
require 'openc3/script'
initialize_offline_access()
return 0
end
def cli_script_list(args, options)
require 'openc3/script'
puts script_list(scope: options[:scope])
return 0
end
def parse_suite_runner_options(options)
suite_runner = nil
# suite must be given to enable Suite Runner execution
if options[:suite]
suite_runner = {}
suite_runner['suite'] = options[:suite]
if options[:group]
suite_runner['group'] = options[:group]
# script requires group to be set
if options[:script]
suite_runner['script'] = options[:script]
end
end
if options[:method]
suite_runner['method'] = options[:method]
else
suite_runner['method'] = 'start'
end
if options[:options]
suite_runner['options'] = options[:options].split(',')
else
suite_runner['options'] = ["continueAfterError"]
end
end
return suite_runner
end
def cli_script_run(args, options)
environment = get_env_from_args(args)
suite_runner = parse_suite_runner_options(options)
ret_code = ERROR_CODE
require 'openc3/script'
if (id = script_run(args[1], disconnect: options[:disconnect], environment: environment, suite_runner: suite_runner, scope: options[:scope]))
puts id
$script_interrupt_text = " Script #{args[1]} still running remotely.\n" # for Ctrl-C
if (options[:wait] < 1) then
ret_code = cli_script_monitor(id)
else
Timeout::timeout(options[:wait], nil, "--wait #{options[:wait]} exceeded") do
ret_code = cli_script_monitor(id)
rescue Timeout::ExitException, Timeout::Error => e
# Timeout exceptions are also raised by the Websocket API, so we check
if e.message =~ /^--wait /
puts e.message + ", detaching from running script #{args[1]}"
else
raise
end
end
end
end
return ret_code
rescue => e
puts "Error running script: #{e.message}"
puts "Have you called 'script init'?"
puts e.backtrace
return ERROR_CODE
end
def cli_script_spawn(args, options)
environment = get_env_from_args(args)
suite_runner = parse_suite_runner_options(options)
ret_code = ERROR_CODE
require 'openc3/script'
if (id = script_run(args[1], disconnect: options[:disconnect], environment: environment, suite_runner: suite_runner, scope: options[:scope]))
puts id
ret_code = 0
end
return ret_code
rescue => e
puts "Error running script: #{e.message}"
puts "Have you called 'script init'?"
puts e.backtrace
return ERROR_CODE
end
def cli_script_running(args, options)
ret_code = ERROR_CODE
require 'openc3/script'
if args[1]
limit = args[1].to_i
else
limit = 100
end
if args[2]
offset = args[2].to_i
else
offset = 0
end
if (list = running_script_list(limit: limit, offset: offset, scope: options[:scope]))
if options[:verbose]
pp list
else
printf("%-5s %-20s %-30s %-22s %-10s\n", "ID", "User", "Filename", "Start Time", "State")
list.each do |hash|
printf("%-5s %-20s %-30s %-22s %-10s\n", hash['name'], hash['user_full_name'], hash['filename'], hash['start_time'], hash['state'])
end
end
ret_code = 0
end
return ret_code
rescue => e
puts "Error getting script status for #{args[1]}: #{e.message}"
puts "Have you called 'script init'?"
puts e.backtrace
return ERROR_CODE
end
def cli_script_status(args, options)
ret_code = ERROR_CODE
require 'openc3/script'
if (hash = script_get(args[1], scope: options[:scope]))
if options[:verbose]
pp hash
else
printf("%-5s %-20s %-30s %-22s %-10s\n", "ID", "User", "Filename", "Start Time", "State")
printf("%-5s %-20s %-30s %-22s %-10s\n", hash['name'], hash['user_full_name'], hash['filename'], hash['start_time'], hash['state'])
end
ret_code = 0
end
return ret_code
rescue => e
puts "Error getting script status for #{args[1]}: #{e.message}"
puts "Have you called 'script init'?"
puts e.backtrace
return ERROR_CODE
end
def cli_script_stop(args, options)
require 'openc3/script'
running_script_stop(args[1], scope: options[:scope])
return 0
rescue => e
puts "Error stopping script #{args[1]}: #{e.message}"
puts "Have you called 'script init'?"
puts e.backtrace
return ERROR_CODE
end
def cli_script(args=[])
options = {scope: 'DEFAULT', disconnect: false, wait: 0, verbose: false}
option_parser = OptionParser.new do |opts|
opts.banner = "Usage: script --scope SCOPE [init | list | spawn | run]\n" +
" init Initialize running scripts (Enterprise Only)\n" +
" list List scripts in the specified scope\n" +
" spawn SCRIPT [ENV=VALUE] Spawn SCRIPT in the specified scope with optional env vars and return script ID\n" +
" run SCRIPT [ENV=VALUE] Run SCRIPT in the specified scope with optional env vars and print script output\n" +
" running [LIMIT] [OFFSET] Get a list of all running scripts (limit 100 by default). Use LIMIT and OFFSET to get large batches.\n" +
" status SCRIPT_ID Get status for the running script given by SCRIPT_ID\n" +
" stop SCRIPT_ID Stop the running script given by SCRIPT_ID\n"
opts.on("-h", "--help", "Show this message") do
puts opts
exit
end
opts.on("--scope SCOPE", "Run with specified scope (default = DEFAULT)") do |arg|
options[:scope] = arg
end
opts.on("--suite SUITE", "Run with specified suite") do |arg|
options[:suite] = arg
end
opts.on("--group GROUP", "Run with specified group") do |arg|
options[:group] = arg
end
opts.on("--script SCRIPT", "Run with specified script") do |arg|
options[:script] = arg
end
opts.on("--method start", "Method must be start, setup, or teardown. Default is start.") do |arg|
options[:method] = arg
end
# TODO 7.0: Should these all be snake case?
opts.on("--options options", "Options is a comma separated list consisting of continueAfterError,pauseOnError,abortAfterError,manual,loop,breakLoopOnError. Default is continueAfterError.") do |arg|
options[:options] = arg
end
opts.on("-d", "--disconnect", "Run a script in disconnect mode (default = false)") do |arg|
options[:disconnect] = arg
end
opts.on("-w SECONDS", "--wait SECONDS", "*run only* - wait for the specified number of seconds before aborting script monitoring") do |arg|
options[:wait] = Integer(arg)
end
opts.on("-v", "--verbose", "*status only* - output ALL status information") do |arg|
options[:verbose] = arg
end
end
begin
option_parser.parse!(args)
rescue
abort(option_parser.to_s)
end
ret_code = ERROR_CODE
check_environment()
# Double check for the OPENC3_API_PASSWORD because it is absolutely required
# We always pass it via openc3.sh even if it's not defined so check for empty
if ENV['OPENC3_API_PASSWORD'].nil? or ENV['OPENC3_API_PASSWORD'].empty?
abort "OPENC3_API_PASSWORD environment variable is required for cli script"
end
# The script command is the first parameter and it is required
command = args[0]
abort(option_parser.to_s) unless command
case command
when 'init'
ret_code = cli_script_init()
when 'list'
ret_code = cli_script_list(args, options)
when 'spawn'
abort(option_parser.to_s) unless args[1] # script file is required
ret_code = cli_script_spawn(args, options)
when 'run'
abort(option_parser.to_s) unless args[1] # script file is required
ret_code = cli_script_run(args, options)
when 'running'
ret_code = cli_script_running(args, options)
when 'status'
abort(option_parser.to_s) unless args[1] # script ID is required
ret_code = cli_script_status(args, options)
when 'stop'
abort(option_parser.to_s) unless args[1] # script ID is required
ret_code = cli_script_stop(args, options)
else
abort 'openc3cli internal error: parsing arguments'
end
exit(ret_code)
end
def migrate_password_hash
password = ENV['OPENC3_API_PASSWORD']
argon2_profile = ENV["OPENC3_ARGON2_PROFILE"]&.to_sym || :rfc_9106_low_memory
if password.nil? or password.empty?
abort "OPENC3_API_PASSWORD environment variable is required for password migration"
end
redis = Redis.new(url: $redis_url, username: ENV['OPENC3_REDIS_USERNAME'], password: ENV['OPENC3_REDIS_PASSWORD'])
old_pw_hash = redis.get('OPENC3__TOKEN')
abort "Password hash already migrated" if old_pw_hash.start_with?('$argon2')
abort "OPENC3_API_PASSWORD incorrect" unless old_pw_hash == Digest::SHA256.hexdigest(password)
new_pw_hash = Argon2::Password.create(password, profile: argon2_profile)
redis.set('OPENC3__TOKEN', new_pw_hash)
puts "Password migrated successfully."
exit 0
end
if not ARGV[0].nil? # argument(s) given
# Handle each task
case ARGV[0].downcase
when 'irb'
if ARGV[1] == '--help' || ARGV[1] == '-h'
puts "Usage: cli irb"
puts ""
puts "Start an interactive Ruby (IRB) session with COSMOS libraries loaded"
puts ""
puts "This provides access to all COSMOS classes and methods for interactive"
puts "debugging and exploration. Use 'exit' or Ctrl-D to quit the IRB session."
puts ""
puts "Example:"
puts " cli irb"
puts " > require 'openc3'"
puts " > OpenC3::System.targets"
puts ""
puts "Note: For IRB-specific options, start IRB first then type 'help'"
exit 0
end
ARGV.clear
IRB.start
when 'script'
cli_script(ARGV[1..-1])
when 'rake'
# Check for --help first, before checking for Rakefile
if ARGV[1] == '--help' || ARGV[1] == '-h'
puts "Usage: cli rake [RAKE_OPTIONS] [TASKS...]"
puts ""
puts "Run rake tasks in the current directory"
puts ""
puts "This runs the standard Ruby rake tool with all COSMOS libraries available."
puts "A Rakefile must exist in the current directory."
puts ""
puts "Common rake options:"
puts " -T, --tasks List all available rake tasks with descriptions"
puts " -D, --describe PATTERN Describe tasks matching PATTERN"
puts " -h, --help Show rake's full help message"
puts ""
puts "Examples:"
puts " cli rake -T # List all available tasks"
puts " cli rake build # Run the 'build' task"
puts " cli rake test # Run tests"
puts ""
puts "Note: Must be run in a directory containing a Rakefile"
exit 0
end
# Now check for Rakefile existence
if File.exist?('Rakefile')
puts `rake #{ARGV[1..-1].join(' ')}`
else
puts "No Rakefile found! Only run 'rake' in the presence of a Rakefile which is typically at the root of your COSMOS project."
end
when 'validate'
if ARGV[1].nil? || ARGV[1] == '--help' || ARGV[1] == '-h'
puts "Usage: cli validate PLUGIN.gem [SCOPE] [VARIABLES.json]"
puts ""
puts "Validate a COSMOS plugin gem file"
puts ""
puts "Arguments:"
puts " PLUGIN.gem Plugin gem file to validate (required)"
puts " SCOPE Scope for validation (optional, default: DEFAULT)"
puts " VARIABLES.json Variables JSON file (optional)"
puts ""
puts "Options:"
puts " -h, --help Show this help message"
exit(ARGV[1].nil? ? 1 : 0)
end
validate_plugin(ARGV[1], scope: ARGV[2], variables_file: ARGV[3])
when 'load'
# Check for help flag or missing arguments
if ARGV[1].nil? || ARGV[1] == '--help' || ARGV[1] == '-h'
puts "Usage:"
puts " cli load PLUGIN.gem"
puts " cli load PLUGIN.gem --variables VARIABLES.json"
puts " cli load PLUGIN.gem SCOPE PLUGIN_HASH.json [force]"
puts " cli load PLUGIN.gem --variables VARIABLES.json SCOPE PLUGIN_HASH.json [force]"
puts ""
puts "Load a COSMOS plugin gem file"
puts ""
puts "Common Usage (Auto-detect):"
puts " cli load PLUGIN.gem"
puts " - Installs new plugin in DEFAULT scope"
puts " - Automatically detects and upgrades if plugin already exists"
puts " - Skips installation if same version already installed"
puts ""
puts " cli load PLUGIN.gem --variables VARIABLES.json"
puts " - Same as above, but provides variables for plugin configuration"
puts ""
puts "Advanced Usage (Manual Control):"
puts " cli load PLUGIN.gem SCOPE PLUGIN_HASH.json [force]"
puts " - Used by Admin UI for plugin create/edit/upgrade operations"
puts " - Requires PLUGIN_HASH.json (output from install_phase1)"
puts " - 'force' argument forces reinstall even if version unchanged"
puts ""
puts "Arguments:"
puts " PLUGIN.gem Plugin gem file to load (required)"
puts " SCOPE Scope to install plugin in (default: DEFAULT)"
puts " PLUGIN_HASH.json Plugin hash JSON file from install_phase1 (optional)"
puts " force Force plugin installation even if no changes (optional)"
puts ""
puts "Options:"
puts " --variables FILE Variables JSON file for plugin installation"
puts " -h, --help Show this help message"
puts ""