diff --git a/openc3-cosmos-cmd-tlm-api/app/controllers/plugins_controller.rb b/openc3-cosmos-cmd-tlm-api/app/controllers/plugins_controller.rb index 27cb4ecf8e..3c43ec05e8 100644 --- a/openc3-cosmos-cmd-tlm-api/app/controllers/plugins_controller.rb +++ b/openc3-cosmos-cmd-tlm-api/app/controllers/plugins_controller.rb @@ -185,6 +185,26 @@ def update create(true) end + # Dry run before an upgrade: returns which modified files differ from the + # incoming plugin's rendered content so the UI can warn the user that those + # modifications will be superseded (a new Version History entry is created). + def modified_diff + return unless authorization('admin') + begin + scope = sanitize_params([:scope]) + return unless scope + scope = scope[0] + plugin_hash = JSON.parse(params[:plugin_hash]) + files = OpenC3::PluginModel.modified_diff(plugin_hash, scope: scope) + render json: { files: files } + rescue JSON::ParserError => error + render json: { status: 'error', message: error.message }, status: :bad_request + rescue Exception => error + logger.error(error.formatted) + render json: { status: 'error', message: error.message }, status: :internal_server_error + end + end + def install return unless authorization('admin') begin @@ -194,8 +214,22 @@ def install temp_dir = Dir.mktmpdir plugin_hash_filename = Dir::Tmpname.create(['plugin-instance-', '.json']) {} plugin_hash_file_path = File.join(temp_dir, File.basename(plugin_hash_filename)) + # Stamp the installing user server-side (never trust a client-supplied + # username) so Version History can attribute plugin-upgrade versions of + # modified files. Falls back to writing the body verbatim if it isn't + # the expected JSON object. + plugin_hash_body = params[:plugin_hash] + begin + parsed = JSON.parse(params[:plugin_hash]) + if parsed.is_a?(Hash) + parsed['username'] = username() + plugin_hash_body = JSON.generate(parsed) + end + rescue JSON::ParserError + # leave plugin_hash_body as the original string + end File.open(plugin_hash_file_path, 'wb') do |file| - file.write(params[:plugin_hash]) + file.write(plugin_hash_body) end gem_name = sanitize_params([:id]) diff --git a/openc3-cosmos-cmd-tlm-api/app/controllers/targets_controller.rb b/openc3-cosmos-cmd-tlm-api/app/controllers/targets_controller.rb index e36a97bb1d..4c0846f651 100644 --- a/openc3-cosmos-cmd-tlm-api/app/controllers/targets_controller.rb +++ b/openc3-cosmos-cmd-tlm-api/app/controllers/targets_controller.rb @@ -48,8 +48,12 @@ def delete_modified return unless authorization('system') scope, id = sanitize_params([:scope, :id], require_params: true) return unless scope + # Optional: delete only specific modified files (e.g. plugin upgrade + # removing non-script modifications while keeping versioned scripts). + files = params[:files] + files = nil unless files.is_a?(Array) begin - @model_class.delete_modified(id, scope: scope) + @model_class.delete_modified(id, scope: scope, files: files) head :ok rescue Exception => e log_error(e) diff --git a/openc3-cosmos-cmd-tlm-api/config/routes.rb b/openc3-cosmos-cmd-tlm-api/config/routes.rb index 2499682f23..acb2692d69 100644 --- a/openc3-cosmos-cmd-tlm-api/config/routes.rb +++ b/openc3-cosmos-cmd-tlm-api/config/routes.rb @@ -92,6 +92,7 @@ resources :permissions, only: [:index] post '/plugins/install/:id', to: 'plugins#install', id: /[^\/]+/ + post '/plugins/modified_diff', to: 'plugins#modified_diff' resources :plugins, only: [:index, :create] get '/plugins/:id', to: 'plugins#show', id: /[^\/]+/ match '/plugins/:id', to: 'plugins#update', id: /[^\/]+/, via: [:patch, :put] diff --git a/openc3-cosmos-cmd-tlm-api/spec/controllers/plugins_controller_spec.rb b/openc3-cosmos-cmd-tlm-api/spec/controllers/plugins_controller_spec.rb index 95d80b9565..caac76e337 100644 --- a/openc3-cosmos-cmd-tlm-api/spec/controllers/plugins_controller_spec.rb +++ b/openc3-cosmos-cmd-tlm-api/spec/controllers/plugins_controller_spec.rb @@ -87,6 +87,30 @@ end end + describe "POST modified_diff" do + it "returns the list of modified files that differ from the plugin" do + allow(OpenC3::PluginModel).to receive(:modified_diff) + .with({"name" => "x"}, scope: "DEFAULT").and_return(["INST/screen.txt"]) + + post :modified_diff, params: {scope: "DEFAULT", plugin_hash: '{"name":"x"}'} + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["files"]).to eq(["INST/screen.txt"]) + end + + it "returns bad_request on invalid plugin_hash JSON" do + post :modified_diff, params: {scope: "DEFAULT", plugin_hash: "not json"} + expect(response).to have_http_status(:bad_request) + json = JSON.parse(response.body) + expect(json["status"]).to eq("error") + end + + it "returns nothing without authorization" do + post :modified_diff, params: {plugin_hash: "{}"} + expect(response).to have_http_status(:unauthorized) + end + end + describe "POST create" do before(:each) do @file = Tempfile.new(["test-plugin", ".gem"]) diff --git a/openc3-cosmos-cmd-tlm-api/spec/controllers/targets_controller_spec.rb b/openc3-cosmos-cmd-tlm-api/spec/controllers/targets_controller_spec.rb index 93fbf0c0c2..9cb94997b1 100644 --- a/openc3-cosmos-cmd-tlm-api/spec/controllers/targets_controller_spec.rb +++ b/openc3-cosmos-cmd-tlm-api/spec/controllers/targets_controller_spec.rb @@ -37,4 +37,27 @@ expect(ret['message']).to eql('Invalid scope: ../DEFAULT') end end + + describe "delete_modified" do + it "forwards a files list to the model" do + expect(OpenC3::TargetModel).to receive(:delete_modified) + .with("INST", scope: "DEFAULT", files: ["INST/screens/a.txt"]) + post :delete_modified, params: { scope: "DEFAULT", id: "INST", files: ["INST/screens/a.txt"] } + expect(response).to have_http_status(:ok) + end + + it "passes files: nil when no files param is given" do + expect(OpenC3::TargetModel).to receive(:delete_modified) + .with("INST", scope: "DEFAULT", files: nil) + post :delete_modified, params: { scope: "DEFAULT", id: "INST" } + expect(response).to have_http_status(:ok) + end + + it "passes files: nil when files is not an array" do + expect(OpenC3::TargetModel).to receive(:delete_modified) + .with("INST", scope: "DEFAULT", files: nil) + post :delete_modified, params: { scope: "DEFAULT", id: "INST", files: "oops" } + expect(response).to have_http_status(:ok) + end + end end diff --git a/openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/api.js b/openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/api.js index ee5fb0d1ee..d980246b26 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/api.js +++ b/openc3-cosmos-init/plugins/packages/openc3-js-common/src/services/api.js @@ -8,7 +8,7 @@ # See LICENSE.md for more details. # Modified by OpenC3, Inc. -# All changes Copyright 2022, OpenC3, Inc. +# All changes Copyright 2026, OpenC3, Inc. # All Rights Reserved # # This file may also be used under the terms of a commercial license @@ -27,6 +27,7 @@ const request = async function ( noAuth = false, noScope = false, onUploadProgress = false, + responseType, } = {}, ) { if (!noAuth) { @@ -56,6 +57,7 @@ const request = async function ( params, headers, onUploadProgress, + responseType, }) } @@ -77,6 +79,7 @@ export default { noScope, noAuth, onUploadProgress, + responseType, } = {}, ) { return request('get', path, { @@ -85,6 +88,7 @@ export default { noScope, noAuth, onUploadProgress, + responseType, }) }, @@ -118,6 +122,7 @@ export default { noScope, noAuth, onUploadProgress, + responseType, } = {}, ) { return request('post', path, { @@ -127,6 +132,7 @@ export default { noScope, noAuth, onUploadProgress, + responseType, }) }, diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/package.json b/openc3-cosmos-init/plugins/packages/openc3-vue-common/package.json index 4cf5005f26..cde30357ba 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/package.json +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/package.json @@ -83,6 +83,7 @@ "date-fns-tz": "3.2.0", "dompurify": "3.4.7", "lodash": "4.18.1", + "monaco-editor": "0.54.0", "pinia": "3.0.4", "sass": "1.100.0", "semver": "7.8.1", diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/admin/ModifiedPluginDialog.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/admin/ModifiedPluginDialog.vue index db8da3a52e..7d29c0e20c 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/admin/ModifiedPluginDialog.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/admin/ModifiedPluginDialog.vue @@ -1,5 +1,5 @@ + + diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/CanvaslabelWidget.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/CanvaslabelWidget.vue index f861352e41..4978ad2e3a 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/CanvaslabelWidget.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/CanvaslabelWidget.vue @@ -11,7 +11,7 @@ # All changes Copyright 2026, OpenC3, Inc. # All Rights Reserved # -# This file may also be used under the terms of a commercial license +# This file may also be used under the terms of a commercial license # if purchased from OpenC3, Inc. --> diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/vite.config.js b/openc3-cosmos-init/plugins/packages/openc3-vue-common/vite.config.js index 4307a09e92..6eb4362aa7 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/vite.config.js +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/vite.config.js @@ -2,9 +2,13 @@ import { resolve } from 'path' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' -export default defineConfig({ +export default defineConfig(({ mode }) => ({ build: { - sourcemap: true, + // Sourcemaps roughly tripled the dist/ footprint after the Monaco swap + // (the dialog chunk's map alone is ~14 MB). Keep them in dev / the + // dev-server build for debugging; drop them from the production build + // that gets baked into the openc3-cosmos-init image. + sourcemap: mode !== 'production', cssCodeSplit: false, lib: { entry: { @@ -53,4 +57,4 @@ export default defineConfig({ }, dedupe: ['single-spa', 'vue', 'vuetify', 'vue-router', 'pinia'], }, -}) +})) diff --git a/openc3-cosmos-init/plugins/pnpm-lock.yaml b/openc3-cosmos-init/plugins/pnpm-lock.yaml index 029848f9d1..ed400ea470 100644 --- a/openc3-cosmos-init/plugins/pnpm-lock.yaml +++ b/openc3-cosmos-init/plugins/pnpm-lock.yaml @@ -832,6 +832,9 @@ importers: lodash: specifier: 4.18.1 version: 4.18.1 + monaco-editor: + specifier: 0.54.0 + version: 0.54.0 pinia: specifier: 3.0.4 version: 3.0.4(vue@3.5.35) @@ -1739,6 +1742,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dompurify@3.1.7: + resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + dompurify@3.4.7: resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} @@ -2115,6 +2121,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2134,6 +2145,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + monaco-editor@0.54.0: + resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3195,6 +3209,8 @@ snapshots: detect-libc@2.1.2: optional: true + dompurify@3.1.7: {} + dompurify@3.4.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3555,6 +3571,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@14.0.0: {} + math-intrinsics@1.1.0: {} mime-db@1.52.0: {} @@ -3569,6 +3587,11 @@ snapshots: mitt@3.0.1: {} + monaco-editor@0.54.0: + dependencies: + dompurify: 3.1.7 + marked: 14.0.0 + ms@2.1.3: {} muuri@0.9.5: {} diff --git a/openc3-cosmos-script-runner-api/app/controllers/script_version_controller.rb b/openc3-cosmos-script-runner-api/app/controllers/script_version_controller.rb new file mode 100644 index 0000000000..00fa41856b --- /dev/null +++ b/openc3-cosmos-script-runner-api/app/controllers/script_version_controller.rb @@ -0,0 +1,23 @@ +# encoding: ascii-8bit + +# Copyright 2026 OpenC3, Inc. +# All Rights Reserved. +# +# 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 LICENSE.md for more details. +# +# This file may also be used under the terms of a commercial license +# if purchased from OpenC3, Inc. + +# Enterprise feature. Real implementation lives in the openc3-enterprise gem +# at lib/openc3-enterprise/controllers/script_version_controller.rb. Core +# build resolves the routes to this empty stub so Rails autoload succeeds; +# requests then 404 on the missing action rather than ActionDispatch::MissingController. +begin + require 'openc3-enterprise/controllers/script_version_controller' +rescue LoadError + class ScriptVersionController + end +end diff --git a/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb b/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb index 089f1cbd7b..a9b6616a92 100644 --- a/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb +++ b/openc3-cosmos-script-runner-api/app/controllers/scripts_controller.rb @@ -17,6 +17,7 @@ require 'json' require 'openc3/utilities/script' +require 'openc3/models/target_model' class ScriptsController < ApplicationController # This REGEX is also found in running_script.rb @@ -57,6 +58,14 @@ def body file = Script.body(scope, name) if file + # Enterprise-only: seed Version History with the deployed body so + # plugin-installed scripts have a baseline commit before any user edit. + # Constant only loaded by the openc3-enterprise gem. Skip __TEMP__ + # scratch scripts — they are throwaway and need no history. + if defined?(::ScriptVersionStore) && !name.start_with?("#{OpenC3::TargetFile::TEMP_FOLDER}/") + plugin = OpenC3::TargetModel.plugin_version_label(name.split('/')[0], scope: scope) + ::ScriptVersionStore.seed_initial_if_empty(scope: scope, name: name, body: file, plugin: plugin) + end locked = Script.locked?(scope, name) unless locked Script.lock(scope, name, username()) @@ -90,6 +99,13 @@ def create args[:name] = name Script.create(args) results = {} + # Enterprise-only: capture a git commit alongside the bucket write so + # the new version_id can travel back to the editor. Skip __TEMP__ scratch + # scripts — they are throwaway and would only add history noise. + if defined?(::ScriptVersionStore) && !name.start_with?("#{OpenC3::TargetFile::TEMP_FOLDER}/") + sha = ::ScriptVersionStore.commit(scope: scope, name: name, text: params[:text], username: username()) + results['version_id'] = sha if sha + end if ((File.extname(name) == '.py') and (params[:text] =~ PYTHON_SUITE_REGEX)) or ((File.extname(name) != '.py') and (params[:text] =~ SUITE_REGEX)) results_suites, results_error, success = Script.process_suite(name, params[:text], username: username(), scope: scope) results['suites'] = results_suites @@ -147,6 +163,10 @@ def destroy scope, name = sanitize_params([:scope, :name], :allow_forward_slash => true) return unless scope Script.destroy(scope, name) + # Enterprise-only: record the deletion in git history. + if defined?(::ScriptVersionStore) + ::ScriptVersionStore.delete(scope: scope, name: name, username: username()) + end OpenC3::Logger.info("Script destroyed: #{name}", scope: scope, user: username()) head :ok rescue => e diff --git a/openc3-cosmos-script-runner-api/config/routes.rb b/openc3-cosmos-script-runner-api/config/routes.rb index 6ea164b263..96f5109f29 100644 --- a/openc3-cosmos-script-runner-api/config/routes.rb +++ b/openc3-cosmos-script-runner-api/config/routes.rb @@ -21,6 +21,20 @@ get "/ping" => "scripts#ping" get "/scripts" => "scripts#index" delete "/scripts/temp_files" => "scripts#delete_temp" + # Enterprise-only Version History export/import. Per-plugin (one git repo + # per installed plugin), so both are keyed by plugin base name, not file + # name. Literal "/scripts/plugin/..." prefix declared before the *name + # catchalls so they don't match scripts#create / scripts#body. + # ScriptVersionController lives in the openc3-enterprise gem; in Core builds + # requests here 500 with uninitialized constant (matches the notebooks/chat + # pattern). + post "/scripts/plugin/:plugin/history-import" => "script_version#import_history" + get "/scripts/plugin/:plugin/history-export" => "script_version#export_history" + # Specific GET routes must precede the catchall body route below — Rails + # matches in declaration order and *name is greedy, so /foo.rb/versions + # would otherwise match #body with name="foo.rb/versions". Enterprise-only. + get "/scripts/*name/versions" => "script_version#versions", format: false, defaults: { format: 'html' } + get "/scripts/*name/version" => "script_version#version_body", format: false, defaults: { format: 'html' } get "/scripts/*name" => "scripts#body", format: false, defaults: { format: 'html' } post "/scripts/*name/run(/:disconnect)" => "scripts#run", format: false, defaults: { format: 'html' } post "/scripts/*name/delete" => "scripts#destroy", format: false, defaults: { format: 'html' } @@ -29,6 +43,8 @@ post "/scripts/*name/syntax" => "scripts#syntax" post "/scripts/*name/mnemonics" => "scripts#mnemonics" post "/scripts/*name/instrumented" => "scripts#instrumented" + # Enterprise-only restore. + post "/scripts/*name/restore" => "script_version#restore", format: false, defaults: { format: 'html' } # Must be last so /run, /delete, etc will match first post "/scripts/*name" => "scripts#create", format: false, defaults: { format: 'html' } diff --git a/openc3/bin/openc3cli b/openc3/bin/openc3cli index 59bb3b5143..da51a0a17d 100755 --- a/openc3/bin/openc3cli +++ b/openc3/bin/openc3cli @@ -226,7 +226,7 @@ ensure exit(result) end -def update_plugin(plugin_file_path, plugin_name, variables: nil, plugin_txt_lines: nil, scope:, existing_plugin_name:, force: false) +def update_plugin(plugin_file_path, plugin_name, variables: nil, plugin_txt_lines: nil, scope:, existing_plugin_name:, force: false, username: nil, version_history_files: nil) 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)}" @@ -247,6 +247,10 @@ def update_plugin(plugin_file_path, plugin_name, variables: nil, plugin_txt_line 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) + # Carry Version History upgrade hints into the regenerated hash so + # install_phase2 can version modified files taken from the plugin. + plugin_hash['username'] = username if username + plugin_hash['version_history_files'] = version_history_files if version_history_files 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) @@ -410,7 +414,8 @@ def load_plugin(plugin_file_path, scope:, plugin_hash_file: nil, force: false, v 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) + plugin_txt_lines: plugin_hash['plugin_txt_lines'], existing_plugin_name: existing_plugin_hash['name'], force: force, + username: plugin_hash['username'], version_history_files: plugin_hash['version_history_files']) else # New Install puts "Loading new plugin: #{plugin_file_path}\n#{plugin_hash}" @@ -447,6 +452,10 @@ def unload_plugin(plugin_name, scope:) # Remove the backing gem now that no PluginModel references it, # so it disappears from the admin Packages tab. OpenC3::PluginModel.cleanup_gem(plugin_name, scope: scope) + # Drop this plugin's Version History repo. Uninstall-only: the upgrade + # path (update_plugin) reuses the repo and never reaches here, so history + # survives version bumps. No-op in Core / when versioning is disabled. + OpenC3::TargetModel.destroy_script_versions(plugin_name, scope: scope) OpenC3::Logger.info("PluginModel destroyed: #{plugin_name}", scope: scope) rescue => e abort("Error uninstalling plugin: #{scope}: #{plugin_name}: #{e.formatted}") diff --git a/openc3/lib/openc3/models/plugin_model.rb b/openc3/lib/openc3/models/plugin_model.rb index deb4c71ea0..8f7974885c 100644 --- a/openc3/lib/openc3/models/plugin_model.rb +++ b/openc3/lib/openc3/models/plugin_model.rb @@ -183,11 +183,24 @@ def self.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_ # 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 - def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false) + # 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). + 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 validate_only + plugin_model.create unless dry_run temp_dir = Dir.mktmpdir begin @@ -204,10 +217,11 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: # 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 - OpenC3::GemModel.destroy_all_other_versions(File.basename(gem_file_path)) + # 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 validate_only + OpenC3::GemModel.install(gem_file_path, scope: scope) unless dry_run # Extract gem contents gem_path = File.join(temp_dir, "gem") @@ -237,8 +251,19 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: img_path = pkg.spec.metadata['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 validate_only + 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')) @@ -247,7 +272,10 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: 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) + # Skipped for dry_run/diff_only: the pip install is a no-op there + # anyway, and resolving pypi_url makes an authorized API call that a dry + # run has no business making. + if (File.exist?(pyproject_path) || File.exist?(requirements_path)) && !dry_run begin pypi_url = get_setting('pypi_url', scope: scope) if pypi_url @@ -265,7 +293,7 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: pypi_url ||= 'https://pypi.org/simple' end end - unless validate_only + unless dry_run if File.exist?(pyproject_path) Logger.info "Installing python packages from pyproject.toml with pypi_url=#{pypi_url}" if ENV['PIP_ENABLE_TRUSTED_HOST'].nil? @@ -303,7 +331,7 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: end if needs_dependencies plugin_model.needs_dependencies = true - plugin_model.update unless validate_only + plugin_model.update unless dry_run end # Temporarily add all lib folders from the gem to the end of the load path @@ -342,8 +370,12 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: when 'TARGET', 'INTERFACE', 'ROUTER', 'MICROSERVICE', 'TOOL', 'WIDGET', 'SCRIPT_ENGINE' begin if current_model - current_model.create unless validate_only - current_model.deploy(gem_path, erb_variables, validate_only: validate_only) + 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 @@ -362,8 +394,12 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: end end if current_model - current_model.create unless validate_only - current_model.deploy(gem_path, erb_variables, validate_only: validate_only) + 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 @@ -374,15 +410,26 @@ def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: end rescue => e # Install failed - need to cleanup - plugin_model.destroy unless validate_only + 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 + # 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. + 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 + def initialize( name:, variables: {}, diff --git a/openc3/lib/openc3/models/target_model.rb b/openc3/lib/openc3/models/target_model.rb index 11d0143d6b..31029c871d 100644 --- a/openc3/lib/openc3/models/target_model.rb +++ b/openc3/lib/openc3/models/target_model.rb @@ -30,6 +30,7 @@ require 'openc3/system' require 'openc3/utilities/local_mode' require 'openc3/utilities/bucket' +require 'openc3/utilities/target_file' require 'openc3/utilities/zip' require 'fileutils' require 'ostruct' @@ -122,6 +123,63 @@ def self.all_modified(scope:) targets.sort.to_h end + # Splits a plugin instance name ("openc3-cosmos-demo-7.2.0.gem__0") into + # [base_name, version] (["openc3-cosmos-demo", "7.2.0"]), or nil when the + # name doesn't carry a version segment. Drops the "__N" install-instance + # suffix and the ".gem" extension first. + def self.plugin_name_version(plugin_instance) + return nil if plugin_instance.nil? || plugin_instance.empty? + gem = plugin_instance.split('__')[0].sub(/\.gem\z/, '') + parts = gem.split('-') + return nil if parts.length < 2 + [parts[0..-2].join('-'), parts[-1]] + end + + # "name version" of the plugin that installed this target (e.g. + # "openc3-cosmos-demo 7.2.0"), derived from the plugin instance name + # ("openc3-cosmos-demo-7.2.0.gem__0"), or nil if unavailable. Used to + # annotate the Version History baseline with the file's origin. + def self.plugin_version_label(target_name, scope:) + model = get(name: target_name, scope: scope) + name, version = plugin_name_version(model && model['plugin']) + return nil unless name + "#{name} #{version}" + rescue + nil + end + + # Version-stripped plugin base name that owns this target, e.g. + # "openc3-cosmos-demo" from instance "openc3-cosmos-demo-7.2.0.gem__0". + # Used as the per-plugin Version History repo key: stable across upgrades + # (only the version segment changes), so history survives version bumps. + # Returns nil when the target has no owning plugin (e.g. a hand-created + # target); callers fall back to a no-plugin bucket. + def self.plugin_base_name(target_name, scope:) + model = get(name: target_name, scope: scope) + name, _version = plugin_name_version(model && model['plugin']) + name + rescue + nil + end + + # Uninstall-only: remove a plugin's entire Version History repo. Called + # from the CLI uninstall path with the plugin instance name; upgrade reuses + # the repo and must never call this. No-op in Core builds (or whenever + # OPENC3_SCRIPT_VERSIONS_DIR is unset) since ScriptVersionStore is absent or + # disabled. Best-effort: a failure here must not abort the uninstall. + def self.destroy_script_versions(plugin_instance_name, scope:) + name, _version = plugin_name_version(plugin_instance_name) + return unless name + begin + require 'openc3-enterprise/utilities/script_version_store' + rescue LoadError + return + end + ::ScriptVersionStore.destroy_repo(scope: scope, plugin: name) + rescue => e + Logger.warn("destroy_script_versions failed for #{scope}/#{plugin_instance_name}: #{e.message}") + end + # Given target's modified file list def self.modified_files(target_name, scope:) modified = [] @@ -144,7 +202,19 @@ def self.modified_files(target_name, scope:) modified.sort end - def self.delete_modified(target_name, scope:) + # files: optional list of specific modified files to delete, each a name + # relative to scope ("TARGET/sub/path", matching modified_files output). + # When given, only those files are removed (used by plugin upgrade to drop + # some modified files while keeping others); when nil, every modified file + # for the target is removed (the original behavior). + def self.delete_modified(target_name, scope:, files: nil) + if files && !files.empty? + files.each do |name| + # TargetFile.destroy handles both local mode and the bucket. + OpenC3::TargetFile.destroy(scope, name) + end + return + end if ENV['OPENC3_LOCAL_MODE'] OpenC3::LocalMode.delete_modified(target_name, scope: scope) end @@ -563,11 +633,23 @@ def handle_config(parser, keyword, parameters) return nil end - def deploy(gem_path, variables, validate_only: false) + # upgrade_context (plugin upgrades only) drives two modes, both keyed by + # the deployed file name "TARGET/sub/path" (matches modified_files output): + # { diff_collector: [] } — dry run (with validate_only): append the names + # of modified files whose live content differs from the rendered plugin + # content. Read-only; used to warn before an upgrade. + # { username:, plugin: "name version", version_files: [...] } — for + # each listed file with a modified copy: record the modified copy in + # Version History, drop the shadow so the plugin content becomes current, + # then commit the incoming content as a plugin-upgrade version. + # nil (the default and Core builds without ScriptVersionStore) = no-op. + def deploy(gem_path, variables, validate_only: false, upgrade_context: nil) variables["target_name"] = @name start_path = "/targets/#{@folder_name}/" temp_dir = Dir.mktmpdir found = false + diff_collector = upgrade_context && upgrade_context[:diff_collector] + versioning = !validate_only && upgrade_context && upgrade_context[:version_files] && script_version_store_available? begin target_path = gem_path + start_path + "**/*" Dir.glob(target_path) do |filename| @@ -603,6 +685,16 @@ def deploy(gem_path, variables, validate_only: false) File.open(local_path, 'wb') { |file| file.write(data) } found = true @bucket.put_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: key, body: data) unless validate_only + + # Plugin upgrade: either collect this file into the diff (dry run) or + # take it from the plugin while preserving the modified copy in + # Version History. Both skip files with no modified copy. + name = "#{@name}/#{target_folder_path}" + if diff_collector + collect_modified_diff(name, data, diff_collector) + elsif versioning && upgrade_context[:version_files].include?(name) + apply_upgrade_version(name, data, upgrade_context) + end end raise "No target files found at #{target_path}" unless found @@ -625,6 +717,56 @@ def deploy(gem_path, variables, validate_only: false) end end + # Version History is an Enterprise feature. The plugin deploy runs in the + # cmd-tlm-api process, which (unlike script-runner-api) doesn't otherwise + # require the store, so lazily load it on demand. Returns false in Core + # builds where the Enterprise gem isn't present. + def script_version_store_available? + return true if defined?(::ScriptVersionStore) + require 'openc3-enterprise/utilities/script_version_store' + defined?(::ScriptVersionStore) ? true : false + rescue LoadError + false + end + + # See deploy/upgrade_context. Reads the modified shadow directly (not + # TargetFile.body, which would fall back to the just-overwritten pristine + # copy), commits it, removes it, then commits the incoming plugin content. + # Best-effort: a versioning failure must not abort the plugin upgrade. + def apply_upgrade_version(name, new_data, ctx) + modified_key = "#{@scope}/targets_modified/#{name}" + resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: modified_key) + return unless resp && resp.body + old_body = resp.body.read + # Nothing actually changed — the modification already matches the plugin. + # Leave the (identical) shadow in place and create no version churn. + return if old_body.b == new_data.b + old_body = old_body.force_encoding('UTF-8') unless File.extname(name) == '.bin' + # Preserve the pre-upgrade (user-modified) content as a version. + # Idempotent: a no-op when it already matches the latest version. + ::ScriptVersionStore.commit(scope: @scope, name: name, text: old_body) + # Drop the modified shadow so the plugin file (already written to + # targets/) becomes the live content. + OpenC3::TargetFile.destroy(@scope, name) + # Commit the incoming plugin content as a plugin-upgrade version, + # attributed to the installing user. + ::ScriptVersionStore.commit(scope: @scope, name: name, text: new_data, + username: ctx[:username], source: 'plugin-upgrade', plugin: ctx[:plugin]) + rescue => e + Logger.warn("Version History upgrade capture failed for #{@scope}/#{name}: #{e.message}") + end + + # Dry-run companion to apply_upgrade_version: append name to collector when + # a modified copy exists and differs (by bytes) from the rendered plugin + # content. Read-only. + def collect_modified_diff(name, new_data, collector) + resp = @bucket.get_object(bucket: ENV['OPENC3_CONFIG_BUCKET'], key: "#{@scope}/targets_modified/#{name}") + return unless resp && resp.body + collector << name if resp.body.read.b != new_data.b + rescue => e + Logger.warn("Modified diff check failed for #{@scope}/#{name}: #{e.message}") + end + def undeploy prefix = "#{@scope}/targets/#{@name}/" objects = @bucket.list_objects(bucket: ENV['OPENC3_CONFIG_BUCKET'], prefix: prefix) diff --git a/openc3/spec/models/plugin_model_spec.rb b/openc3/spec/models/plugin_model_spec.rb index f206837ca7..376eff5752 100644 --- a/openc3/spec/models/plugin_model_spec.rb +++ b/openc3/spec/models/plugin_model_spec.rb @@ -285,11 +285,110 @@ module OpenC3 # Just stub the instance deploy method expect(GemModel).to receive(:install).and_return(nil) expect_any_instance_of(ToolModel).to receive(:deploy).with(anything, erb_variables, validate_only: false).and_return(nil) - expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, erb_variables, validate_only: false).and_return(nil) + expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, erb_variables, validate_only: false, upgrade_context: nil).and_return(nil) plugin_model = PluginModel.install_phase2({"name" => "name", "variables" => variables, "plugin_txt_lines" => ["TOOL THE_FOLDER THE_NAME", " #{URL}", "TARGET THE_FOLDER THE_NAME"]}, scope: "DEFAULT") expect(plugin_model['needs_dependencies']).to eql false end + it "threads version_history_files and username into the TargetModel upgrade_context" do + s3 = instance_double("Aws::S3::Client").as_null_object + allow(Aws::S3::Client).to receive(:new).and_return(s3) + + expect(GemModel).to receive(:get).and_return("my_plugin.gem") + gem = double("gem") + expect(gem).to receive(:extract_files) do |path| + File.open("#{path}/plugin.txt", 'w') do |file| + file.puts "TOOL <%= folder %> <%= name %>" + file.puts " #{URL}" + file.puts "TARGET <%= folder %> <%= name %>" + end + end + expect(Gem::Package).to receive(:new).and_return(gem) + spec = double("spec") + allow(gem).to receive(:spec).and_return(spec) + allow(spec).to receive(:name).and_return("test-plugin") + allow(spec).to receive(:version).and_return("1.0.0") + allow(spec).to receive(:runtime_dependencies).and_return([]) + allow(spec).to receive(:metadata).and_return({}) + allow(spec).to receive(:summary).and_return("Test plugin") + allow(spec).to receive(:description).and_return("Test plugin description") + allow(spec).to receive(:licenses).and_return([]) + allow(spec).to receive(:homepage).and_return(nil) + + variables = { "folder" => { "value" => "THE_FOLDER" }, "name" => { "value" => "THE_NAME" } } + erb_variables = { "folder" => "THE_FOLDER", "name" => "THE_NAME", "scope" => 'DEFAULT' } + expect(GemModel).to receive(:install).and_return(nil) + expect_any_instance_of(ToolModel).to receive(:deploy).with(anything, erb_variables, validate_only: false).and_return(nil) + # The upgrade hints (username + version_history_files) are stripped from + # the plugin_hash and rebuilt into the TargetModel upgrade_context with + # the resolved "name version" plugin label. + expect_any_instance_of(TargetModel).to receive(:deploy).with( + anything, erb_variables, validate_only: false, + upgrade_context: { username: "upgrader", + plugin: "test-plugin 1.0.0", + version_files: ["THE_NAME/screen.txt"] } + ).and_return(nil) + PluginModel.install_phase2({"name" => "name", "variables" => variables, + "username" => "upgrader", "version_history_files" => ["THE_NAME/screen.txt"], + "plugin_txt_lines" => ["TOOL THE_FOLDER THE_NAME", " #{URL}", "TARGET THE_FOLDER THE_NAME"]}, scope: "DEFAULT") + end + + it "passes a diff_collector upgrade_context and makes no side effects when diff_only" do + s3 = instance_double("Aws::S3::Client").as_null_object + allow(Aws::S3::Client).to receive(:new).and_return(s3) + + expect(GemModel).to receive(:get).and_return("my_plugin.gem") + gem = double("gem") + expect(gem).to receive(:extract_files) do |path| + File.open("#{path}/plugin.txt", 'w') do |file| + file.puts "TARGET <%= folder %> <%= name %>" + end + end + expect(Gem::Package).to receive(:new).and_return(gem) + spec = double("spec") + allow(gem).to receive(:spec).and_return(spec) + allow(spec).to receive(:name).and_return("test-plugin") + allow(spec).to receive(:version).and_return("1.0.0") + allow(spec).to receive(:runtime_dependencies).and_return([]) + allow(spec).to receive(:metadata).and_return({}) + allow(spec).to receive(:summary).and_return("Test plugin") + allow(spec).to receive(:description).and_return("Test plugin description") + allow(spec).to receive(:licenses).and_return([]) + allow(spec).to receive(:homepage).and_return(nil) + + variables = { "folder" => { "value" => "THE_FOLDER" }, "name" => { "value" => "THE_NAME" } } + erb_variables = { "folder" => "THE_FOLDER", "name" => "THE_NAME", "scope" => 'DEFAULT' } + # Dry run: no gem install and the deploy is validate_only with a + # diff_collector array for the target to append modified files into. + expect(GemModel).to_not receive(:install) + expect(GemModel).to_not receive(:destroy_all_other_versions) + expect_any_instance_of(TargetModel).to receive(:deploy).with( + anything, erb_variables, validate_only: true, + upgrade_context: { diff_collector: [] } + ).and_return(nil) + result = PluginModel.install_phase2({"name" => "name", "variables" => variables, + "plugin_txt_lines" => ["TARGET THE_FOLDER THE_NAME"]}, scope: "DEFAULT", diff_only: true) + # Returns the (deduped) collected diff list rather than the plugin json. + expect(result).to eql([]) + end + end + + describe "self.modified_diff" do + it "delegates to install_phase2 with diff_only and returns the file list" do + expect(PluginModel).to receive(:install_phase2).with( + hash_including("name" => "name"), scope: "DEFAULT", diff_only: true + ).and_return(["TGT/screen.txt"]) + expect(PluginModel.modified_diff({"name" => "name"}, scope: "DEFAULT")).to eql(["TGT/screen.txt"]) + end + + it "returns an empty array and logs when install_phase2 raises" do + expect(PluginModel).to receive(:install_phase2).and_raise("boom") + expect(Logger).to receive(:warn).with(/modified_diff failed: boom/) + expect(PluginModel.modified_diff({"name" => "name"}, scope: "DEFAULT")).to eql([]) + end + end + + describe "self.install_phase2 errors" do it "raises on non-lowercase screen file names" do s3 = instance_double("Aws::S3::Client").as_null_object allow(Aws::S3::Client).to receive(:new).and_return(s3) @@ -371,7 +470,7 @@ module OpenC3 # Just stub the instance deploy method expect(GemModel).to receive(:install).and_return(nil) expect_any_instance_of(ToolModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false).and_return(nil) - expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false).and_return(nil) + expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false, upgrade_context: nil).and_return(nil) plugin_model = PluginModel.install_phase2({"name" => "name", "variables" => {}, "plugin_txt_lines" => plugin_txt_lines}, scope: "DEFAULT") expect(plugin_model['needs_dependencies']).to eql true end @@ -407,7 +506,7 @@ module OpenC3 # Just stub the instance deploy method expect(GemModel).to receive(:install).and_return(nil) expect_any_instance_of(ToolModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false).and_return(nil) - expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false).and_return(nil) + expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false, upgrade_context: nil).and_return(nil) plugin_model = PluginModel.install_phase2({"name" => "name", "variables" => {}, "plugin_txt_lines" => plugin_txt_lines}, scope: "DEFAULT") expect(plugin_model['needs_dependencies']).to eql true end @@ -444,7 +543,7 @@ module OpenC3 # Just stub the instance deploy method expect(GemModel).to receive(:install).and_return(nil) expect_any_instance_of(ToolModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false).and_return(nil) - expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false).and_return(nil) + expect_any_instance_of(TargetModel).to receive(:deploy).with(anything, {"scope" => 'DEFAULT'}, validate_only: false, upgrade_context: nil).and_return(nil) plugin_model = PluginModel.install_phase2({"name" => "name", "variables" => {}, "plugin_txt_lines" => plugin_txt_lines}, scope: "DEFAULT") expect(plugin_model['needs_dependencies']).to eql true end diff --git a/openc3/spec/models/target_model_spec.rb b/openc3/spec/models/target_model_spec.rb index bf5fe5a98d..1de905d1ef 100644 --- a/openc3/spec/models/target_model_spec.rb +++ b/openc3/spec/models/target_model_spec.rb @@ -159,6 +159,198 @@ module OpenC3 dels = TargetModel.delete_modified('TEST', scope: "DEFAULT") expect(dels).to match_array([] )# return empty array when none modified end + + it "deletes only the given files via TargetFile when a files list is passed" do + expect(OpenC3::TargetFile).to receive(:destroy).with("DEFAULT", "TEST/screens/a.txt") + expect(OpenC3::TargetFile).to receive(:destroy).with("DEFAULT", "TEST/lib/b.rb") + TargetModel.delete_modified('TEST', scope: "DEFAULT", files: ["TEST/screens/a.txt", "TEST/lib/b.rb"]) + end + + it "falls back to deleting all modified files when the files list is empty" do + # Empty list takes the original (delete-all) path, not the per-file path + expect(OpenC3::TargetFile).to_not receive(:destroy) + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT") + model.create + TargetModel.delete_modified('TEST', scope: "DEFAULT", files: []) + end + end + + describe "self.plugin_version_label" do + it "returns the plugin name and version derived from the plugin instance name" do + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT", + plugin: "openc3-cosmos-demo-7.2.0.gem__0") + model.create + expect(TargetModel.plugin_version_label("TEST", scope: "DEFAULT")).to eql("openc3-cosmos-demo 7.2.0") + end + + it "returns nil when the target does not exist" do + expect(TargetModel.plugin_version_label("NOPE", scope: "DEFAULT")).to be_nil + end + + it "returns nil when the target has no plugin" do + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT") + model.create + expect(TargetModel.plugin_version_label("TEST", scope: "DEFAULT")).to be_nil + end + + it "returns nil when the plugin name has no version segment" do + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT", + plugin: "singleword__0") + model.create + expect(TargetModel.plugin_version_label("TEST", scope: "DEFAULT")).to be_nil + end + end + + describe "self.plugin_name_version" do + it "splits a plugin instance name into base name and version" do + expect(TargetModel.plugin_name_version("openc3-cosmos-demo-7.2.0.gem__0")).to eql(["openc3-cosmos-demo", "7.2.0"]) + end + + it "drops the .gem extension when no instance suffix is present" do + expect(TargetModel.plugin_name_version("openc3-cosmos-demo-7.2.0.gem")).to eql(["openc3-cosmos-demo", "7.2.0"]) + end + + it "returns nil for nil, empty, or version-less names" do + expect(TargetModel.plugin_name_version(nil)).to be_nil + expect(TargetModel.plugin_name_version("")).to be_nil + expect(TargetModel.plugin_name_version("singleword__0")).to be_nil + end + end + + describe "self.plugin_base_name" do + it "returns the version-stripped base name for the target's plugin" do + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT", + plugin: "openc3-cosmos-demo-7.2.0.gem__0") + model.create + expect(TargetModel.plugin_base_name("TEST", scope: "DEFAULT")).to eql("openc3-cosmos-demo") + end + + it "is stable across version upgrades (only the version segment changes)" do + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT", + plugin: "openc3-cosmos-demo-7.3.0.gem__0") + model.create + expect(TargetModel.plugin_base_name("TEST", scope: "DEFAULT")).to eql("openc3-cosmos-demo") + end + + it "returns nil when the target does not exist" do + expect(TargetModel.plugin_base_name("NOPE", scope: "DEFAULT")).to be_nil + end + + it "returns nil when the target has no plugin" do + model = TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT") + model.create + expect(TargetModel.plugin_base_name("TEST", scope: "DEFAULT")).to be_nil + end + end + + describe "self.destroy_script_versions" do + it "calls ScriptVersionStore.destroy_repo with the base name when the store is present" do + store = Class.new do + def self.calls; @calls ||= []; end + def self.destroy_repo(scope:, plugin:); calls << [scope, plugin]; end + end + stub_const("ScriptVersionStore", store) + # Skip the enterprise require; the constant is already defined. + allow(TargetModel).to receive(:require).with("openc3-enterprise/utilities/script_version_store").and_return(true) + TargetModel.destroy_script_versions("openc3-cosmos-demo-7.2.0.gem__0", scope: "DEFAULT") + expect(store.calls).to eql([["DEFAULT", "openc3-cosmos-demo"]]) + end + + it "is a no-op for a version-less plugin name" do + store = Class.new do + def self.called; @called ||= false; end + def self.destroy_repo(scope:, plugin:); @called = true; end + end + stub_const("ScriptVersionStore", store) + TargetModel.destroy_script_versions("singleword__0", scope: "DEFAULT") + expect(store.called).to be false + end + + it "is a no-op when the enterprise store gem is absent" do + hide_const("ScriptVersionStore") if defined?(ScriptVersionStore) + allow(TargetModel).to receive(:require).with("openc3-enterprise/utilities/script_version_store").and_raise(LoadError) + expect { TargetModel.destroy_script_versions("openc3-cosmos-demo-7.2.0.gem__0", scope: "DEFAULT") }.not_to raise_error + end + end + + describe "plugin upgrade Version History helpers" do + let(:model) { TargetModel.new(folder_name: "TEST", name: "TEST", scope: "DEFAULT") } + let(:bucket) { double("bucket") } + before(:each) { model.instance_variable_set(:@bucket, bucket) } + + def shadow_response(body) + double("resp", body: double("io", read: body)) + end + + describe "#collect_modified_diff" do + it "appends the name when the modified copy differs from the plugin content" do + allow(bucket).to receive(:get_object).and_return(shadow_response("user version")) + collector = [] + model.send(:collect_modified_diff, "TEST/screen.txt", "plugin version", collector) + expect(collector).to eql(["TEST/screen.txt"]) + end + + it "does not append when the modified copy matches the plugin content" do + allow(bucket).to receive(:get_object).and_return(shadow_response("same")) + collector = [] + model.send(:collect_modified_diff, "TEST/screen.txt", "same", collector) + expect(collector).to be_empty + end + + it "does not append when there is no modified copy" do + allow(bucket).to receive(:get_object).and_return(nil) + collector = [] + model.send(:collect_modified_diff, "TEST/screen.txt", "anything", collector) + expect(collector).to be_empty + end + + it "logs and swallows bucket errors" do + allow(bucket).to receive(:get_object).and_raise("boom") + expect(Logger).to receive(:warn).with(/Modified diff check failed/) + collector = [] + expect { model.send(:collect_modified_diff, "TEST/screen.txt", "x", collector) }.to_not raise_error + expect(collector).to be_empty + end + end + + describe "#apply_upgrade_version" do + let(:store) { double("ScriptVersionStore") } + before(:each) { stub_const("ScriptVersionStore", store) } + + it "versions the modified copy, drops the shadow, then versions the plugin content" do + allow(bucket).to receive(:get_object).and_return(shadow_response("modified")) + ctx = { username: "upgrader", plugin: "test-plugin 1.0.0", version_files: ["TEST/screen.txt"] } + # Order: commit the pre-upgrade modified body, remove the shadow, then + # commit the incoming plugin content attributed to the upgrade. + expect(store).to receive(:commit).with(scope: "DEFAULT", name: "TEST/screen.txt", text: "modified").ordered + expect(OpenC3::TargetFile).to receive(:destroy).with("DEFAULT", "TEST/screen.txt").ordered + expect(store).to receive(:commit).with(scope: "DEFAULT", name: "TEST/screen.txt", text: "plugin", + username: "upgrader", source: 'plugin-upgrade', plugin: "test-plugin 1.0.0").ordered + model.send(:apply_upgrade_version, "TEST/screen.txt", "plugin", ctx) + end + + it "does nothing when the modified copy already matches the plugin content" do + allow(bucket).to receive(:get_object).and_return(shadow_response("identical")) + expect(store).to_not receive(:commit) + expect(OpenC3::TargetFile).to_not receive(:destroy) + model.send(:apply_upgrade_version, "TEST/screen.txt", "identical", { username: "u", plugin: "p 1", version_files: [] }) + end + + it "does nothing when there is no modified copy" do + allow(bucket).to receive(:get_object).and_return(nil) + expect(store).to_not receive(:commit) + model.send(:apply_upgrade_version, "TEST/screen.txt", "plugin", { username: "u", plugin: "p 1", version_files: [] }) + end + + it "logs and swallows errors so the upgrade is not aborted" do + allow(bucket).to receive(:get_object).and_return(shadow_response("modified")) + allow(store).to receive(:commit).and_raise("git boom") + expect(Logger).to receive(:warn).with(/Version History upgrade capture failed/) + expect { + model.send(:apply_upgrade_version, "TEST/screen.txt", "plugin", { username: "u", plugin: "p 1", version_files: [] }) + }.to_not raise_error + end + end end describe "self.download" do diff --git a/playwright/tests/admin/plugins.p.spec.ts b/playwright/tests/admin/plugins.p.spec.ts index 9f9f5ca636..bdf5e57279 100644 --- a/playwright/tests/admin/plugins.p.spec.ts +++ b/playwright/tests/admin/plugins.p.spec.ts @@ -514,8 +514,9 @@ test.describe(() => { await expect(page.locator('.v-dialog:has-text("Variables")')).toBeVisible() await page.locator('data-test=edit-submit').click() await expect(page.locator('.v-dialog:has-text("Modified")')).toBeVisible() - // Check the delete box - await page.locator('text=DELETE MODIFIED').click() + // The modified file is a script, so it gets a per-file choice that + // defaults to "Install file from plugin" (recoverable via Version + // History). Just confirm to take the plugin's version. await page.locator('data-test=modified-plugin-submit').click() await expect(page.locator('[data-test=plugin-alert]')).toContainText( 'Started installing',