Class: OpenC3::PluginModel

Inherits:
Model show all
Extended by:
Api
Defined in:
lib/openc3/models/plugin_model.rb

Overview

Represents a OpenC3 plugin that can consist of targets, interfaces, routers microservices and tools. The PluginModel installs all these pieces as well as destroys them all when the plugin is removed.

Constant Summary collapse

PRIMARY_KEY =
'openc3_plugins'
RESERVED_VARIABLE_NAMES =

Reserved VARIABLE names. See local_mode.rb: update_local_plugin()

['target_name', 'microservice_name', 'scope']
PLUGIN_TXT =
'plugin.txt'

Constants included from Api

Api::DELAY_METRICS, Api::DURATION_METRICS, Api::SUBSCRIPTION_DELIMITER, Api::SUM_METRICS

Constants included from ApiShared

ApiShared::DEFAULT_TLM_POLLING_RATE

Constants included from Extract

Extract::SCANNING_REGULAR_EXPRESSION

Instance Attribute Summary collapse

Attributes inherited from Model

#name, #plugin, #scope, #updated_at

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Api

_cal_to_epoch, _cmd_implementation, _extract_target_command_names, _extract_target_command_parameter_names, _extract_target_packet_item_names, _extract_target_packet_names, _get_and_set_cmd, _get_item, _limits_group, _set_tlm_process_args, _tlm_process_args, _validate_tlm_type, build_cmd, cmd, cmd_no_checks, cmd_no_hazardous_check, cmd_no_range_check, cmd_raw, cmd_raw_no_checks, cmd_raw_no_hazardous_check, cmd_raw_no_range_check, commit_timeline_activity, config_tool_names, connect_interface, connect_router, count_timeline_activities, create_timeline, create_timeline_activity, delete_config, delete_limits_set, delete_timeline, delete_timeline_activity, disable_cmd, disable_limits, disable_limits_group, disconnect_interface, disconnect_router, enable_cmd, enable_limits, enable_limits_group, get_all_cmd_names, get_all_cmds, get_all_interface_info, get_all_router_info, get_all_settings, get_all_tlm, get_all_tlm_item_names, get_all_tlm_names, get_cmd, get_cmd_buffer, get_cmd_cnt, get_cmd_cnts, get_cmd_hazardous, get_cmd_time, get_cmd_value, get_interface, get_interface_names, get_item, get_limits, get_limits_events, get_limits_groups, get_limits_set, get_limits_sets, get_metrics, get_out_of_limits, get_overall_limits_state, get_overrides, get_packet_derived_items, get_packets, get_param, get_router, get_router_names, get_setting, get_settings, get_target, get_target_interfaces, get_target_names, get_timeline, get_timeline_activities, get_timeline_activity, get_tlm, get_tlm_available, get_tlm_buffer, get_tlm_cnt, get_tlm_cnts, get_tlm_packet, get_tlm_values, inject_tlm, interface_cmd, interface_details, interface_protocol_cmd, interface_target_disable, interface_target_enable, limits_enabled?, list_configs, list_settings, list_timelines, load_config, map_target_to_interface, map_target_to_router, normalize_tlm, offline_access_needed, override_tlm, router_cmd, router_details, router_protocol_cmd, router_target_disable, router_target_enable, save_config, send_raw, set_limits, set_limits_set, set_offline_access, set_setting, set_state_color, set_timeline_color, set_timeline_execute, set_tlm, start_raw_logging_interface, start_raw_logging_router, stash_all, stash_delete, stash_get, stash_keys, stash_set, stop_raw_logging_interface, stop_raw_logging_router, subscribe_packets, tlm, tlm_formatted, tlm_raw, tlm_with_units, unmap_target_from_interface, unmap_target_from_router, update_news, update_plugin_store, update_timeline_activity

Methods included from CmdLog

#_build_cmd_output_string

Methods inherited from Model

#check_disable_erb, #deploy, #destroy, #destroyed?, #diff, filter, find_all_by_plugin, from_json, get_all_models, get_model, handle_config, set, store, store_queued, #update

Constructor Details

#initialize(name:, variables: {}, plugin_txt_lines: [], minimum_cosmos_version: nil, needs_dependencies: false, store_plugin_id: nil, store_version_id: nil, title: nil, description: nil, keywords: nil, licenses: nil, homepage: nil, repository: nil, img_path: nil, updated_at: nil, scope:) ⇒ PluginModel

Returns a new instance of PluginModel.



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
# File 'lib/openc3/models/plugin_model.rb', line 431

def initialize(
  name:,
  variables: {},
  plugin_txt_lines: [],
  minimum_cosmos_version: nil,
  needs_dependencies: false,
  store_plugin_id: nil,
  store_version_id: nil,
  title: nil,
  description: nil,
  keywords: nil,
  licenses: nil,
  homepage: nil,
  repository: nil,
  img_path: nil,
  updated_at: nil,
  scope:
)
  super("#{scope}__#{PRIMARY_KEY}", name: name, updated_at: updated_at, scope: scope)
  @variables = variables
  @plugin_txt_lines = plugin_txt_lines
  @minimum_cosmos_version = minimum_cosmos_version
  @needs_dependencies = ConfigParser.handle_true_false(needs_dependencies)
  @store_plugin_id = store_plugin_id
  @store_version_id = store_version_id
  @title = title
  @description = description
  @keywords = keywords
  @licenses = licenses
  @homepage = homepage
  @repository = repository
  @img_path = img_path
end

Instance Attribute Details

#descriptionObject

Returns the value of attribute description.



61
62
63
# File 'lib/openc3/models/plugin_model.rb', line 61

def description
  @description
end

#homepageObject

Returns the value of attribute homepage.



63
64
65
# File 'lib/openc3/models/plugin_model.rb', line 63

def homepage
  @homepage
end

#img_pathObject

Returns the value of attribute img_path.



66
67
68
# File 'lib/openc3/models/plugin_model.rb', line 66

def img_path
  @img_path
end

#keywordsObject

Returns the value of attribute keywords.



65
66
67
# File 'lib/openc3/models/plugin_model.rb', line 65

def keywords
  @keywords
end

#licensesObject

Returns the value of attribute licenses.



62
63
64
# File 'lib/openc3/models/plugin_model.rb', line 62

def licenses
  @licenses
end

#minimum_cosmos_versionObject

Returns the value of attribute minimum_cosmos_version.



56
57
58
# File 'lib/openc3/models/plugin_model.rb', line 56

def minimum_cosmos_version
  @minimum_cosmos_version
end

#needs_dependenciesObject

Returns the value of attribute needs_dependencies.



57
58
59
# File 'lib/openc3/models/plugin_model.rb', line 57

def needs_dependencies
  @needs_dependencies
end

#plugin_txt_linesObject

Returns the value of attribute plugin_txt_lines.



55
56
57
# File 'lib/openc3/models/plugin_model.rb', line 55

def plugin_txt_lines
  @plugin_txt_lines
end

#repositoryObject

Returns the value of attribute repository.



64
65
66
# File 'lib/openc3/models/plugin_model.rb', line 64

def repository
  @repository
end

#store_plugin_idObject

Returns the value of attribute store_plugin_id.



58
59
60
# File 'lib/openc3/models/plugin_model.rb', line 58

def store_plugin_id
  @store_plugin_id
end

#store_version_idObject

Returns the value of attribute store_version_id.



59
60
61
# File 'lib/openc3/models/plugin_model.rb', line 59

def store_version_id
  @store_version_id
end

#titleObject

Returns the value of attribute title.



60
61
62
# File 'lib/openc3/models/plugin_model.rb', line 60

def title
  @title
end

#variablesObject

Returns the value of attribute variables.



54
55
56
# File 'lib/openc3/models/plugin_model.rb', line 54

def variables
  @variables
end

Class Method Details

.all(scope: nil) ⇒ Object



78
79
80
# File 'lib/openc3/models/plugin_model.rb', line 78

def self.all(scope: nil)
  super("#{scope}__#{PRIMARY_KEY}")
end

.build_pypi_args(pypi_url) ⇒ Object

Build the argv array for pypi index and trusted-host arguments.



608
609
610
611
612
# File 'lib/openc3/models/plugin_model.rb', line 608

def self.build_pypi_args(pypi_url)
  args = ["-i", pypi_url]
  args += ["--trusted-host", URI.parse(pypi_url).host] unless ENV['PIP_ENABLE_TRUSTED_HOST'].nil?
  args
end

.cleanup_gem(plugin_name, scope:) ⇒ Object

Remove the backing gem for an unloaded plugin so it disappears from GemModel.names (and the admin Packages tab). Skips the removal if any other PluginModel (any scope, any counter) still references the gem, via the existing PluginModel.gem_names check inside GemModel.destroy.



689
690
691
692
693
694
# File 'lib/openc3/models/plugin_model.rb', line 689

def self.cleanup_gem(plugin_name, scope:)
  gem_filename = plugin_name.split("__")[0]
  OpenC3::GemModel.destroy(gem_filename, log_and_raise_needed_errors: false)
rescue => e
  Logger.warn("Could not remove gem #{gem_filename}: #{e.message}", scope: scope)
end

.gem_namesObject

Get list of plugin gem names across all scopes to prevent uninstall of gems from GemModel



575
576
577
578
579
580
581
582
583
584
585
586
# File 'lib/openc3/models/plugin_model.rb', line 575

def self.gem_names
  result = []
  scopes = ScopeModel.names
  scopes.each do |scope|
    plugin_names = self.names(scope: scope)
    plugin_names.each do |plugin_name|
      gem_name = plugin_name.split("__")[0]
      result << gem_name unless result.include?(gem_name)
    end
  end
  return result.sort
end

.get(name:, scope: nil) ⇒ Object

NOTE: The following three class methods are used by the ModelController and are reimplemented to enable various Model class methods to work



70
71
72
# File 'lib/openc3/models/plugin_model.rb', line 70

def self.get(name:, scope: nil)
  super("#{scope}__#{PRIMARY_KEY}", name: name)
end

.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_txt_lines: nil, store_plugin_id: nil, store_version_id: nil, process_existing: false, scope:, validate_only: false) ⇒ Object

Called by the PluginsController to parse the plugin variables Doesn't actually create the plugin during the phase



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
# File 'lib/openc3/models/plugin_model.rb', line 84

def self.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_txt_lines: nil, store_plugin_id: nil, store_version_id: nil, process_existing: false, scope:, validate_only: false)
  gem_name = File.basename(gem_file_path).split("__")[0]

  temp_dir = Dir.mktmpdir
  tf = nil
  begin
    if File.exist?(gem_file_path)
      if File.zero?(gem_file_path)
        raise EmptyGemFileError, "Gem file is empty: #{gem_file_path}"
      end

      # Load gem to internal gem server
      OpenC3::GemModel.put(gem_file_path, gem_install: false, scope: scope) unless validate_only
    else
      gem_file_path = OpenC3::GemModel.get(gem_name)
    end

    # Extract gem and process plugin.txt to determine what VARIABLEs need to be filled in
    pkg = Gem::Package.new(gem_file_path)

    minimum_cosmos_version = pkg.spec.['openc3_cosmos_minimum_version']

    if existing_plugin_txt_lines and process_existing
      # This is only used in openc3cli load when everything is known
      plugin_txt_lines = existing_plugin_txt_lines
      file_data = existing_plugin_txt_lines.join("\n")
      tf = Tempfile.new(PLUGIN_TXT)
      tf.write(file_data)
      tf.close
      plugin_txt_path = tf.path
    else
      # Otherwise we always process the new and return both
      pkg.extract_files(temp_dir)
      plugin_txt_path = File.join(temp_dir, PLUGIN_TXT)
      plugin_text = File.read(plugin_txt_path)
      plugin_txt_lines = []
      plugin_text.each_line do |line|
        plugin_txt_lines << line.chomp
      end
    end

    parser = OpenC3::ConfigParser.new("https://openc3.com")

    # Phase 1 Gather Variables
    variables = {}
    current_variable_name = nil
    parser.parse_file(plugin_txt_path,
                      false,
                      true,
                      false) do |keyword, params|
      case keyword
      when 'VARIABLE'
        usage = "#{keyword} <Variable Name> <Default Value>"
        parser.verify_num_parameters(2, nil, usage)
        variable_name = params[0]
        if RESERVED_VARIABLE_NAMES.include?(variable_name)
          raise "VARIABLE name '#{variable_name}' is reserved"
        end
        value = params[1..-1].join(" ")
        variables[variable_name] = { 'value' => value }
        current_variable_name = variable_name
        if existing_variables && existing_variables.key?(variable_name)
          existing = existing_variables[variable_name]
          # Handle both old format (string) and new format (hash)
          if existing.is_a?(Hash)
            variables[variable_name]['value'] = existing['value']
          else
            variables[variable_name]['value'] = existing
          end
        end
      when 'VARIABLE_DESCRIPTION'
        usage = "#{keyword} <Description>"
        parser.verify_num_parameters(1, 1, usage)
        unless current_variable_name
          raise "VARIABLE_DESCRIPTION must follow a VARIABLE definition"
        end
        variables[current_variable_name]['description'] = params[0]
      when 'VARIABLE_STATE'
        usage = "#{keyword} <Display Text> <Value>"
        parser.verify_num_parameters(1, 2, usage)
        unless current_variable_name
          raise "VARIABLE_STATE must follow a VARIABLE definition"
        end
        variables[current_variable_name]['options'] ||= []
        option = { 'value' => params[1] || params[0], 'text' => params[0] }
        variables[current_variable_name]['options'] << option
      end
    end

    store_plugin_id = Integer(store_plugin_id) if store_plugin_id
    store_version_id = Integer(store_version_id) if store_version_id
    model = PluginModel.new(name: gem_name, variables: variables, plugin_txt_lines: plugin_txt_lines, store_plugin_id: store_plugin_id, store_version_id: store_version_id, minimum_cosmos_version: minimum_cosmos_version, scope: scope)
    result = model.as_json()
    result['existing_plugin_txt_lines'] = existing_plugin_txt_lines if existing_plugin_txt_lines and not process_existing and existing_plugin_txt_lines != result['plugin_txt_lines']
    return result
  ensure
    FileUtils.remove_entry_secure(temp_dir, true)
    tf.unlink if tf
  end
end

.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false, diff_only: false) ⇒ Object

Called by the PluginsController to create the plugin Because this uses ERB it must be run in a separate process from the API to prevent corruption and single require problems in the current process diff_only: dry run that renders the plugin's target files and returns the list of modified files ("TARGET/path") whose live content differs from what this plugin would deploy — used to warn before an upgrade which user modifications it would supersede. Implies validate_only (no side effects).



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
# File 'lib/openc3/models/plugin_model.rb', line 192

def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false, diff_only: false)
  # diff_only implies a dry run; derive a local flag instead of mutating
  # the validate_only parameter.
  dry_run = validate_only || diff_only
  # Register plugin to aid in uninstall if install fails
  plugin_hash.delete("existing_plugin_txt_lines")
  # Version History upgrade hints (threaded from the admin install through
  # update_plugin). Extracted before the splat below because PluginModel
  # has no such attributes. version_history_files lists modified files the
  # user chose to take from the plugin; username attributes the upgrade.
  upgrade_username = plugin_hash.delete("username")
  upgrade_version_files = plugin_hash.delete("version_history_files")
  plugin_model = PluginModel.new(**(plugin_hash.transform_keys(&:to_sym)), scope: scope)
  plugin_model.create unless dry_run

  temp_dir = Dir.mktmpdir
  begin
    tf = nil

    # Get the gem from local gem server if it hasn't been passed
    if gem_file_path.nil?
      gem_name = plugin_hash['name'].split("__")[0]
      gem_file_path = OpenC3::GemModel.get(gem_name)
    else
      gem_name = File.basename(gem_file_path)
    end

    # Attempt to remove all older versions of this same plugin before install to prevent version conflicts
    # Especially on downgrades
    # Leave the same version if it already exists
    # Skipped for dry_run/diff_only: a dry run must not mutate gems.
    OpenC3::GemModel.destroy_all_other_versions(File.basename(gem_file_path)) unless dry_run

    # Actually install the gem now (slow)
    OpenC3::GemModel.install(gem_file_path, scope: scope) unless dry_run

    # Extract gem contents
    gem_path = File.join(temp_dir, "gem")
    FileUtils.mkdir_p(gem_path)
    pkg = Gem::Package.new(gem_file_path)
    pkg.extract_files(gem_path)
    Dir[File.join(gem_path, '**/screens/*.txt')].each do |filename|
      if File.basename(filename) != File.basename(filename).downcase
        raise "Invalid screen filename: #{filename}. Screen filenames must be lowercase."
      end
    end

    plugin_model.minimum_cosmos_version = pkg.spec.['openc3_cosmos_minimum_version']

    # Process OpenC3 Store metadata
    plugin_model.title = pkg.spec.['openc3_store_title'] || pkg.spec.summary.strip
    plugin_model.description = pkg.spec.['openc3_store_description'] || pkg.spec.description.strip
    plugin_model.licenses = pkg.spec.licenses
    plugin_model.homepage = pkg.spec.homepage
    plugin_model.repository = pkg.spec.['source_code_uri'] # this key because it's in the official gemspec examples
    plugin_model.keywords = pkg.spec.['openc3_store_keywords']&.split(/, ?/)
    # Resolve the plugin's store image path. The gemspec generated by
    # `cli generate plugin` sets `openc3_store_image` to `public/store_img.png`
    # but does not create that file, so we must verify existence regardless
    # of whether the path came from metadata or the default — otherwise the
    # frontend hits a 500 when fetching the missing file.
    img_path = pkg.spec.['openc3_store_image'] || 'public/store_img.png'
    img_path = nil unless File.exist?(File.join(gem_path, img_path))
    package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
    # Build the upgrade context once the gem version is known; TargetModel
    # deploys use it either to collect a modified-file diff (diff_only) or
    # to version modified files taken from the plugin.
    upgrade_context = nil
    if diff_only
      upgrade_context = { diff_collector: [] }
    elsif upgrade_version_files && !upgrade_version_files.empty?
      upgrade_context = { username: upgrade_username,
                          plugin: "#{pkg.spec.name} #{pkg.spec.version}",
                          version_files: upgrade_version_files }
    end
    plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
    plugin_model.update() unless dry_run

    needs_dependencies = pkg.spec.runtime_dependencies.length > 0
    needs_dependencies = true if Dir.exist?(File.join(gem_path, 'lib'))

    # Handle python dependencies (pyproject.toml or requirements.txt)
    pyproject_path = File.join(gem_path, 'pyproject.toml')
    requirements_path = File.join(gem_path, 'requirements.txt')

    if File.exist?(pyproject_path) || File.exist?(requirements_path)
      pypi_url = resolve_pypi_url(scope: scope)
      unless validate_only
        pypi_args = build_pypi_args(pypi_url)

        # Install Python dependencies into an isolated per-plugin venv when UV
        # is available. Each plugin gets its own venv at /gems/plugin_venvs/<name>/.venv
        # so that plugins with conflicting Python dependency versions don't interfere.
        # If UV is unavailable or the install fails, fall back to the shared pipinstall
        # which installs into PYTHONUSERBASE (the legacy shared environment).
        uv_installed = ENV['OPENC3_USE_UV'] != 'false' && system('which uv > /dev/null 2>&1')
        if uv_installed
          plugin_venv_name = plugin_venv_name(scope: scope, plugin_name: plugin_model.name)
          Logger.info "Installing python packages into per-plugin venv '#{plugin_venv_name}' with pypi_url=#{pypi_url}"
          uv_args = [plugin_venv_name, gem_path] + pypi_args
          output, status = Open3.capture2e("/openc3/bin/uvinstall", *uv_args)
          puts output
          unless status.success?
            Logger.warn "UV per-plugin install failed, falling back to shared pipinstall"
            uv_installed = false
          end
        end

        unless uv_installed
          pip_args = pypi_args.dup
          if File.exist?(pyproject_path)
            Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}"
            pip_args << gem_path
          else
            Logger.info "Installing python packages from requirements.txt with pypi_url=#{pypi_url}"
            pip_args += ["-r", requirements_path]
          end
          # Capture output and check exit code so failures surface as a warning
          # rather than silently succeeding. pipinstall is non-fatal: the plugin
          # continues to install even if Python packages fail so that non-Python
          # functionality still works.
          output, status = Open3.capture2e("/openc3/bin/pipinstall", *pip_args)
          puts output
          unless status.success?
            Logger.warn "Python package installation failed. Plugin Python microservices may not function correctly."
          end
        end
      end
      needs_dependencies = true
    end

    # If needs_dependencies hasn't already been set we need to scan the plugin.txt
    # to see if they've explicitly set the NEEDS_DEPENDENCIES keyword
    unless needs_dependencies
      if plugin_hash['plugin_txt_lines'].join("\n").include?('NEEDS_DEPENDENCIES')
        needs_dependencies = true
      end
    end
    if needs_dependencies
      plugin_model.needs_dependencies = true
      plugin_model.update unless dry_run
    end

    # Temporarily add all lib folders from the gem to the end of the load path
    load_dirs = []
    begin
      Dir.glob("#{gem_path}/**/*").each do |load_dir|
        if File.directory?(load_dir) and File.basename(load_dir) == 'lib'
          load_dirs << load_dir
          $LOAD_PATH << load_dir
        end
      end

      # Process plugin.txt file
      file_data = plugin_hash['plugin_txt_lines'].join("\n")
      tf = Tempfile.new(PLUGIN_TXT)
      tf.write(file_data)
      tf.close
      plugin_txt_path = tf.path
      variables = plugin_hash['variables']
      variables ||= {}
      # Extract simple key-value pairs for ERB substitution
      # Variables can be either new format (hash with 'value' key) or old format (string)
      erb_variables = {}
      variables.each do |name, var|
        erb_variables[name] = var.is_a?(Hash) ? var['value'] : var
      end
      erb_variables['scope'] = scope
      if File.exist?(plugin_txt_path)
        parser = OpenC3::ConfigParser.new("https://openc3.com")

        current_model = nil
        parser.parse_file(plugin_txt_path, false, true, true, erb_variables) do |keyword, params|
          case keyword
          when 'VARIABLE', 'VARIABLE_DESCRIPTION', 'VARIABLE_STATE', 'NEEDS_DEPENDENCIES'
            # Ignore during phase 2
          when 'TARGET', 'INTERFACE', 'ROUTER', 'MICROSERVICE', 'TOOL', 'WIDGET', 'SCRIPT_ENGINE'
            begin
              if current_model
                current_model.create unless dry_run
                if current_model.is_a?(OpenC3::TargetModel)
                  current_model.deploy(gem_path, erb_variables, validate_only: dry_run, upgrade_context: upgrade_context)
                else
                  current_model.deploy(gem_path, erb_variables, validate_only: dry_run)
                end
              end
            # If something goes wrong in create, or more likely in deploy,
            # we want to clear the current_model and try to instantiate the next
            # Otherwise we're stuck constantly iterating on the last model
            ensure
              current_model = nil
              current_model = OpenC3.const_get((keyword.split('_').collect(&:capitalize).join + 'Model').intern).handle_config(parser,
                keyword, params, plugin: plugin_model.name, needs_dependencies: needs_dependencies, scope: scope)
            end
          else
            if current_model
              current_model.handle_config(parser, keyword, params)
            else
              raise "Invalid keyword '#{keyword}' in plugin.txt"
            end
          end
        end
        if current_model
          current_model.create unless dry_run
          if current_model.is_a?(OpenC3::TargetModel)
            current_model.deploy(gem_path, erb_variables, validate_only: dry_run, upgrade_context: upgrade_context)
          else
            current_model.deploy(gem_path, erb_variables, validate_only: dry_run)
          end
          current_model = nil
        end
      end
    ensure
      load_dirs.each do |load_dir|
        $LOAD_PATH.delete(load_dir)
      end
    end
  rescue => e
    # Install failed - need to cleanup
    plugin_model.destroy unless dry_run
    raise e
  ensure
    FileUtils.remove_entry_secure(temp_dir, true)
    tf.unlink if tf
  end
  return upgrade_context[:diff_collector].uniq if diff_only
  return plugin_model.as_json()
end

.modified_diff(plugin_hash, scope:) ⇒ Object

Dry run: which modified files would this plugin's install supersede? Returns a list of "TARGET/path" names whose live (modified) content differs from the rendered plugin content. Read-only; no side effects.



424
425
426
427
428
429
# File 'lib/openc3/models/plugin_model.rb', line 424

def self.modified_diff(plugin_hash, scope:)
  install_phase2(plugin_hash, scope: scope, diff_only: true)
rescue => e
  Logger.warn("PluginModel.modified_diff failed: #{e.message}")
  []
end

.names(scope: nil) ⇒ Object



74
75
76
# File 'lib/openc3/models/plugin_model.rb', line 74

def self.names(scope: nil)
  super("#{scope}__#{PRIMARY_KEY}")
end

.plugin_venv_name(scope:, plugin_name:) ⇒ Object

Build a sanitized venv directory name from scope and plugin name. Replaces characters that are not alphanumeric, underscore, or hyphen with underscores.



616
617
618
# File 'lib/openc3/models/plugin_model.rb', line 616

def self.plugin_venv_name(scope:, plugin_name:)
  "#{scope}__#{plugin_name}".tr('^a-zA-Z0-9_-', '_')
end

.resolve_pypi_url(scope:) ⇒ Object

Resolve the PyPI URL from settings, environment, or default. Used by both install_phase2 and migrate_to_uv! to avoid duplication.



590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# File 'lib/openc3/models/plugin_model.rb', line 590

def self.resolve_pypi_url(scope:)
  pypi_url = nil
  begin
    pypi_url = get_setting('pypi_url', scope: scope)
    pypi_url += '/simple' if pypi_url
  rescue => e
    Logger.error("Failed to retrieve pypi_url: #{e.formatted}")
  ensure
    if pypi_url.nil?
      pypi_url = ENV['PYPI_URL']
      pypi_url += '/simple' if pypi_url
      pypi_url ||= PypiUrl::DEFAULT
    end
  end
  PypiUrl.validate(pypi_url)
end

Instance Method Details

#as_json(*_a) ⇒ Object



475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# File 'lib/openc3/models/plugin_model.rb', line 475

def as_json(*_a)
  {
    'name' => @name,
    'variables' => @variables,
    'plugin_txt_lines' => @plugin_txt_lines,
    'minimum_cosmos_version' => @minimum_cosmos_version,
    'needs_dependencies' => @needs_dependencies,
    'store_plugin_id' => @store_plugin_id,
    'store_version_id' => @store_version_id,
    'title' => @title,
    'description' => @description,
    'keywords' => @keywords,
    'licenses' => @licenses,
    'homepage' => @homepage,
    'repository' => @repository,
    'img_path' => @img_path,
    'updated_at' => @updated_at
  }
end

#create(update: false, force: false, queued: false) ⇒ Object



465
466
467
468
469
470
471
472
473
# File 'lib/openc3/models/plugin_model.rb', line 465

def create(update: false, force: false, queued: false)
  if not update and not @name.index("__")
    existing_names = Set.new(self.class.names(scope: @scope))
    counter = 0
    counter += 1 while existing_names.include?("#{@name}__#{counter}")
    @name = "#{@name}__#{counter}"
  end
  super(update: update, force: force, queued: queued)
end

#migrate_to_uv!(scope:) ⇒ Object

Migrate this plugin from the shared Python venv to a per-plugin UV virtual environment. Non-fatal: logs a warning on failure, plugin continues using the shared venv. Returns true on success, false on failure.



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
# File 'lib/openc3/models/plugin_model.rb', line 633

def migrate_to_uv!(scope:)
  plugin_venv_name = self.class.plugin_venv_name(scope: scope, plugin_name: @name)
  marker_path = File.join('/gems', 'plugin_venvs', plugin_venv_name, '.uv_managed')
  if File.exist?(marker_path)
    Logger.info("Plugin '#{@name}' is already migrated to a per-plugin UV venv")
    return true
  end

  gem_name = @name.split("__")[0]
  gem_file_path = OpenC3::GemModel.get(gem_name)

  temp_dir = Dir.mktmpdir
  begin
    # Extract gem contents (same pattern as install_phase2)
    gem_path = File.join(temp_dir, "gem")
    FileUtils.mkdir_p(gem_path)
    pkg = Gem::Package.new(gem_file_path)
    pkg.extract_files(gem_path)

    # Check for Python dependency files
    pyproject_path = File.join(gem_path, 'pyproject.toml')
    requirements_path = File.join(gem_path, 'requirements.txt')

    unless File.exist?(pyproject_path) || File.exist?(requirements_path)
      Logger.info("Plugin #{@name} has no Python dependencies to migrate")
      return true
    end

    pypi_url = self.class.resolve_pypi_url(scope: scope)
    pypi_args = self.class.build_pypi_args(pypi_url)

    # Run uvinstall for this plugin
    plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
    Logger.info("Migrating plugin '#{@name}' to per-plugin UV venv '#{plugin_venv_name}'")
    uv_args = [plugin_venv_name, gem_path] + pypi_args
    output, status = Open3.capture2e("/openc3/bin/uvinstall", *uv_args)
    puts output
    if status.success?
      Logger.info("Successfully migrated plugin '#{@name}' to per-plugin UV venv")
      return true
    else
      Logger.warn("UV migration failed for plugin '#{@name}'. Plugin will continue using shared venv.")
      return false
    end
  rescue => e
    Logger.warn("UV migration failed for plugin '#{@name}': #{e.message}. Plugin will continue using shared venv.")
    return false
  ensure
    FileUtils.remove_entry_secure(temp_dir, true)
  end
end

#needs_uv_migration?Boolean

Check if this plugin needs migration to a per-plugin UV virtual environment. Returns true if the plugin has Python dependencies but no .uv_managed marker exists.

Returns:

  • (Boolean)


622
623
624
625
626
627
628
# File 'lib/openc3/models/plugin_model.rb', line 622

def needs_uv_migration?
  return false unless @needs_dependencies

  plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
  marker_path = File.join('/gems', 'plugin_venvs', plugin_venv_name, '.uv_managed')
  !File.exist?(marker_path)
end

#restoreObject

Reinstall



568
569
570
571
572
# File 'lib/openc3/models/plugin_model.rb', line 568

def restore
  plugin_hash = self.as_json()
  OpenC3::PluginModel.install_phase2(plugin_hash, scope: @scope)
  @destroyed = false
end

#undeployObject

Undeploy all models associated with this plugin



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
# File 'lib/openc3/models/plugin_model.rb', line 496

def undeploy
  errors = []
  microservice_count = 0
  microservices = MicroserviceModel.find_all_by_plugin(plugin: @name, scope: @scope)
  microservices.each do |_name, model_instance|
    begin
      model_instance.destroy
    rescue Exception => e
      errors << e
    end
    microservice_count += 1
  end
  # Wait for the operator to wake up and remove the microservice processes
  sleep 15 if microservice_count > 0 # Cycle time 5s times 2 plus 5s wait for soft stop and then hard stop
  # Remove all the other models now that the processes have stopped
  # Save TargetModel for last as it has the most to cleanup
  [InterfaceModel, RouterModel, ToolModel, WidgetModel, TargetModel, ScriptEngineModel].each do |model|
    model.find_all_by_plugin(plugin: @name, scope: @scope).each do |_name, model_instance|
      begin
        model_instance.destroy
      rescue Exception => e
        errors << e
      end
    end
  end
  # Cleanup Redis stuff that might have been left by microservices
  microservices.each do |_name, model_instance|
    begin
      model_instance.cleanup
    rescue Exception => e
      errors << e
    end
  end
  # Remove the per-plugin UV virtual environment directory that was created
  # during install_phase2. This cleans up the .venv, pyproject.toml, uv.lock,
  # and .uv_managed marker so disk space is reclaimed on plugin uninstall.
  begin
    plugin_venv_name = self.class.plugin_venv_name(scope: @scope, plugin_name: @name)
    plugin_venv_path = File.join('/gems', 'plugin_venvs', plugin_venv_name)
    if File.directory?(plugin_venv_path)
      Logger.info("Removing per-plugin Python venv: #{plugin_venv_path}")
      FileUtils.rm_rf(plugin_venv_path)
    end
  rescue Exception => e
    errors << e
  end
  # Raise all the errors at once
  if errors.length > 0
    message = ''
    errors.each do |error|
      message += "\n#{error.formatted}\n"
    end
    raise message
  end
rescue Exception => e
  Logger.error("Error undeploying plugin model #{@name} in scope #{@scope} due to: #{e.formatted}")
ensure
  # Double check everything is gone
  found = []
  [MicroserviceModel, InterfaceModel, RouterModel, ToolModel, WidgetModel, TargetModel, ScriptEngineModel].each do |model|
    model.find_all_by_plugin(plugin: @name, scope: @scope).each do |_name, model_instance|
      found << model_instance
    end
  end
  if found.length > 0
    # If undeploy failed we need to not move forward with anything else
    Logger.error("Error undeploying plugin model #{@name} in scope #{@scope} due to: Plugin submodels still exist after undeploy = #{found.length}")
    raise "Plugin #{@name} submodels still exist after undeploy = #{found.length}"
  end
end