diff --git a/toolbox/core/bst_containers.m b/toolbox/core/bst_containers.m new file mode 100644 index 0000000000..9df20b9b35 --- /dev/null +++ b/toolbox/core/bst_containers.m @@ -0,0 +1,415 @@ +function varargout = bst_containers(varargin) +% BST_CONTAINERS: Manage containers for container-based plugins in Brainstorm +% +% USAGE: +% [errMsg, engineName] = bst_containers('GetEngine') +% [errMsg, imageList] = bst_containers('GetImages') +% [errMsg, imageSha] = bst_containers('ImportImage', imageSource, [imageTag]) +% errMsg = bst_containers('RunContainer', containerName, imageSha, [volumes], [isDaemon]) +% [errMsg, cmdout] = bst_containers('ExecInContainer', containerName, cmdStr) +% [errMsg, containerInfo] = bst_containers('GetContainerInfo', containerName) +% errMsg = bst_containers('StopContainer', containerName, [isForced=0]) +% errMsg = bst_containers('RemoveImage', imageSha/Name, [isForced=0]) + +% @============================================================================= +% This function is part of the Brainstorm software: +% https://neuroimage.usc.edu/brainstorm +% +% Copyright (c) University of Southern California & McGill University +% This software is distributed under the terms of the GNU General Public License +% as published by the Free Software Foundation. Further details on the GPLv3 +% license can be found at http://www.gnu.org/copyleft/gpl.html. +% +% FOR RESEARCH PURPOSES ONLY. THE SOFTWARE IS PROVIDED "AS IS," AND THE +% UNIVERSITY OF SOUTHERN CALIFORNIA AND ITS COLLABORATORS DO NOT MAKE ANY +% WARRANTY, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NOR DO THEY ASSUME ANY +% LIABILITY OR RESPONSIBILITY FOR THE USE OF THIS SOFTWARE. +% +% For more information type "brainstorm license" at command prompt. +% =============================================================================@ +% +% Authors: Raymundo Cassani, 2026 +% Takfarinas Medani, 2026 + +eval(macro_method); +end + + +%% ===== GET CONTAINER ENGINE ===== +function [errMsg, engineName] = GetEngine(engineName) +% USAGE: [errMsg, engineName] = bst_containers('GetEngine') % Find, test and set a supported container engine +% [errMsg, engineName] = bst_containers('GetEngine', engineName) % Test the requested container engine + errMsg = ''; + + % Get and test all the supported container engines + if nargin < 1 || isempty(engineName) || strcmpi(engineName, 'auto-detect') + [~, engineNames] = bst_get('ContainerEngine'); + % Remove 'auto-detect', first element in engineNames + engineNames(1) = []; + engineName = ''; + isSetDefault = 1; + % Test only the requested container engine + else + engineNames = {engineName}; + isSetDefault = 0; + end + + % Tests container engines + isFound = 0; + for iEngine = 1 : length(engineNames) + switch engineNames{iEngine} + case {'docker'} + if ispc + [status, cmdout] = system(['where ' engineNames{iEngine}]); + if status == 0 + cmdout = strsplit(strtrim(cmdout), '\n'); + if ~isempty(cmdout) + isFound = 1; + enginePath = strtrim(cmdout{1}); + end + end + else + [status, cmdout] = system(['which ' engineNames{iEngine}]); + if status == 0 + isFound = 1; + enginePath = strtrim(cmdout); + end + end + end + % Break loop if found + if isFound + engineName = engineNames{iEngine}; + break + end + end + % Return if not found + if ~isFound + if isempty(engineName) + errMsg = 'No valid container engine was found'; + else + errMsg = ['Container engine ' engineName ' was not found']; + end + return + % Set as default the container engine found + elseif isSetDefault + bst_set('ContainerEngine', engineName); + end + + % Check the container engine status + switch engineName + case 'docker' + [status, cmdout] = system([engineName ' info']); + cmdout = strtrim(cmdout); + if status == 1 || ~isempty(strfind(lower(cmdout), 'failed')) || ~isempty(strfind(lower(cmdout), 'error')) + errMsg = cmdout; + return + end + end +end + + +%% ===== GET AVAILABLE IMAGES ===== +function [errMsg, imageList] = GetImages() +% USAGE: [errMsg, imageList] = bst_containers('GetImages') + imageList = cell(0,2); % [Name:Tag, SHA] + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + + % List of available images + switch engineName + case 'docker' + [status, cmdout] = system('docker images --all --no-trunc --format "{{.Repository}}:{{.Tag}} {{.ID}}"'); + if status == 0 && ~isempty(cmdout) + imageList = strsplit(strtrim(strrep(cmdout, char(10), ' ')), ' '); + if mod(length(imageList), 2) ~= 0 + errMsg = 'Error parsing Docker image list'; + return + end + imageList = reshape(imageList, 2, [])'; + elseif status ~= 0 + errMsg = cmdout; + end + end +end + + +%% ===== IMPORT IMAGE ===== +function [errMsg, imageSha] = ImportImage(imageSource, imageTag) +% Import container image into container engine, and create a tag +% USAGE: [errMsg, imageSha] = bst_containers('ImportImage', imageSource, [imageTag]) + imageSha = ''; + + if (nargin < 2) || isempty(imageTag) + imageTag = ''; + end + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + + % Default: imageSource is an image reference + imageType = 'reference'; + % If imageSource is a URL, download image file + if ~isempty(regexp(imageSource, '^http[s]*://', 'once')) + % Get tmp dir to bind container + tmpDir = bst_get('BrainstormTmpDir', 0, 'pull_image'); + imageFile = bst_fullfile(tmpDir, 'image.tgz'); + disp(['BST> Downloading URL : ' imageSource]); + disp(['BST> Saving to file : ' imageFile]); + errMsg = gui_brainstorm('DownloadFile', imageSource, imageFile, 'Download container image: '); + if ~isempty(errMsg) + errMsg = ['Impossible to download container image automatically:' 10 errMsg]; + return + end + imageSource = imageFile; + imageType = 'file'; + end + + % Get current available images + if ~isempty(imageTag) + [errMsg, imageListOld] = GetImages(); + if ~isempty(errMsg) + return + end + end + + % Import image + switch engineName + case 'docker' + switch imageType + case 'reference' + [status, cmdout] = system(['docker pull ' imageSource]); + if status == 0 + % If new or existent image, SHA256 is returned in output + imageSha = regexp(cmdout, 'sha256:[a-f0-9]+', 'match', 'once'); + end + + case 'file' + [status, cmdout] = system(['docker load --input ' imageSource]); + if status == 0 + % If new or existent image, Image name (or SHA256 for nameless image) is returned in output + token = regexp(cmdout, '[a-z0-9._-]+:[a-zA-Z0-9._-]+', 'match', 'once'); + parts = strsplit(token, ':'); + if strcmp(parts{1}, 'sha256') && ~isempty(regexp(parts{2}, '^[a-f0-9]+$', 'once')) + imageSha = token; + else + [~, imageListNew] = GetImages(); + imageSha = imageListNew{strcmpi(imageListNew(:,1), token), 2}; + end + end + end + % Tag image + if status == 0 && ~isempty(imageTag) + % Compare images before and after import + [~, imageListNew] = GetImages(); + iOld = find(strcmpi(imageListOld(:,2), imageSha)); + iNew = find(strcmpi(imageListNew(:,2), imageSha)); + % Tag image + [status, cmdout] = system(['docker tag ', imageSha, ' ', imageTag]); + % Keep only the tag image IF the image was added in this call to ImportImage() + if status == 0 && (length(iNew) - length(iOld)) == 1 + if ~isempty(imageListOld) + imageDel = setdiff(imageListNew{iNew, 1}, imageListOld{iOld, 1}); + else + imageDel = imageListNew{iNew, 1}; + end + if ~strcmpi(imageDel, ':') + [status, cmdout] = system(['docker rmi ', imageListNew{iNew, 1}]); + end + end + end + if status ~= 0 + errMsg = cmdout; + return + end + end +end + + +%% ===== RUN CONTAINER AS DAEMON ===== +function errMsg = RunContainer(containerName, imageSha, volumes, isDaemon) +% USAGE: errMsg = bst_containers('RunContainer', containerName, imageSha, volumes, isDaemon) + % Validate inputs + if nargin < 4 || isempty(isDaemon) + isDaemon = 0; + end + if nargin < 3 || ~iscell(volumes) || size(volumes,2) ~=2 + volumes = []; + end + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + + % Create volumes pairs + volumesStr = ''; + if ~isempty(volumes) + nPairs = size(volumes, 1); + pairs = cell(nPairs, 1); + for iPair = 1 : nPairs + pairs{iPair} = ['-v ' volumes{iPair, 1} ':' volumes{iPair, 2}]; + end + volumesStr = strjoin(pairs, ' '); + end + + % Run container + switch engineName + case 'docker' + if ~isDaemon + % Run ENTRYPOINT + cmdStr = sprintf('docker run --rm --name %s %s %s', containerName, volumesStr, imageSha); + else + % Replace ENTRYPOINT (if any) with `sleep infinity` + cmdStr = sprintf('docker run -d --name %s %s --entrypoint sleep %s infinity', containerName, volumesStr, imageSha); + end + [status, cmdout] = system(cmdStr); + end + if status ~= 0 + errMsg = cmdout; + end +end + + +%% ===== EXECUTE COMMAND IN CONTAINER ===== +function [errMsg, cmdout] = ExecInContainer(containerName, cmdStr) + cmdout = ''; + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + % Check if container is running + [errMsg, containerInfo] = GetContainerInfo(containerName); + if ~isempty(errMsg) || ~containerInfo.isRunning + return + end + + % Run command + switch engineName + case 'docker' + if ispc + commandWrapper = '"'; % Double quote + else + commandWrapper = ''''; % Single quote + end + [status, cmdout] = system(['docker exec ' containerName ' sh -c ' commandWrapper cmdStr commandWrapper]); + if status ~= 0 + errMsg = strtrim(cmdout); + end + end +end + + +%% ===== CHECK CONTAINER STATUS ===== +function [errMsg, containerInfo] = GetContainerInfo(containerName) +% [containerNameOut, isRunning, volumePairs, imageSha] + containerInfo = struct(); + containerInfo.name = ''; + containerInfo.isRunning = 0; + containerInfo.volumes = []; + containerInfo.imageSha = ''; + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + + % Search for existent container with the same name and image reference + switch engineName + case 'docker' + % Find containers with same name + [status, cmdout] = system(['docker inspect ' containerName ' --format "{{.Name}}"']); + if status ~= 0 + errMsg = strtrim(cmdout); + return + end + containerInfo.name = strrep(strtrim(cmdout), '/', ''); + [status, cmdout] = system(['docker inspect ' containerName ' --format "'... + '{{.State.Status}} # {{.HostConfig.Binds}} # {{.Image}}"']); + if status ~= 0 + errMsg = strtrim(cmdout); + return + end + cmdout = strsplit(strtrim(cmdout), '#'); + containerInfo.isRunning = strcmpi('running', strtrim(cmdout{1})); + volumes = regexprep(strtrim(cmdout{2}), '^\[|\]$', ''); + volumes = regexprep(volumes, ':\', ';\'); + volumePairs = strsplit(volumes, ':'); + volumePairs = cellfun(@(x) regexprep(x, ';\', ':\'), volumePairs, 'UniformOutput', 0); + containerInfo.volumes = reshape(volumePairs, 2, [])'; + tokens = regexp(cmdout{3}, 'sha256:[a-f0-9]+', 'match'); + containerInfo.imageSha = strtrim(tokens{1}); + end +end + + +%% ===== STOP CONTAINER ===== +function errMsg = StopContainer(containerName, isForce) + % Validate inputs + if nargin < 2 || isempty(isForce) + isForce = 0; + end + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + + % Stop container + switch engineName + case 'docker' + if ~isForce + % Stop and remove + [status, cmdout] = system(['docker stop ' containerName ' && docker rm ' containerName]); + else + % Kill + [status, cmdout] = system(['docker rm -f ' containerName]); + end + if status ~=0 + errMsg = strtrim(cmdout); + end + end +end + + +%% ===== REMOVE IMAGE ===== +function errMsg = RemoveImage(imageSha, isForce) + % Validate inputs + if nargin < 2 || isempty(isForce) + isForce = 0; + end + + % Check status of default container engine + [errMsg, engineName] = GetEngine(bst_get('ContainerEngine')); + if ~isempty(errMsg) + return + end + + % Remove image + switch engineName + case 'docker' + if ~isForce + % Remove image + [status, cmdout] = system(['docker rmi ' imageSha]); + else + % Force remove image + [status, cmdout] = system(['docker rmi -f ' imageSha]); + end + if status ~=0 + errMsg = strtrim(cmdout); + end + end +end + diff --git a/toolbox/core/bst_exit.m b/toolbox/core/bst_exit.m index f3e2bd8468..bd2fda637a 100644 --- a/toolbox/core/bst_exit.m +++ b/toolbox/core/bst_exit.m @@ -1,6 +1,6 @@ function status = bst_exit() % BST_EXIT: Exit Brainstorm -% Save database, remove callbacks, close windows, reset environment. +% Save database, remove callbacks, close windows, reset environment, stop containers % % Return : 1 if exited % 0 if Brainstorm was not started @@ -99,6 +99,15 @@ bst_mutex('release', 'Brainstorm'); +%% ===== STOP CONTAINERS ===== +PlugDesc = bst_plugin('GetInstalled'); +for iPlug = 1 : length(PlugDesc) + if bst_plugin('IsContainer', PlugDesc(iPlug)) + bst_plugin('Unload', PlugDesc(iPlug), 0); + end +end + + %% ===== SAVE DATABASE ===== db_save(1); % Close file to indicate that Brainstorm was started diff --git a/toolbox/core/bst_get.m b/toolbox/core/bst_get.m index 6a908886c5..999781fcea 100644 --- a/toolbox/core/bst_get.m +++ b/toolbox/core/bst_get.m @@ -2994,6 +2994,16 @@ end end + case 'ContainerEngine' + containerEngines = {'auto-detect', 'docker'}; + % Get saved value + if isfield(GlobalData, 'Preferences') && isfield(GlobalData.Preferences, 'ContainerEngine') && ~isempty(GlobalData.Preferences.ContainerEngine) + argout1 = GlobalData.Preferences.ContainerEngine; + else + argout1 = containerEngines{1}; + end + argout2 = containerEngines; + case 'ElectrodeConfig' % Get modality Modality = varargin{2}; diff --git a/toolbox/core/bst_plugin.m b/toolbox/core/bst_plugin.m index 5639a3e889..72a12a9254 100644 --- a/toolbox/core/bst_plugin.m +++ b/toolbox/core/bst_plugin.m @@ -1684,8 +1684,10 @@ function Configure(PlugDesc) PlugDesc = []; return; end + % Check if plugin is a container + isContainer = IsContainer(PlugDesc); % Check if there is a URL to download - if isempty(PlugDesc.URLzip) + if isempty(PlugDesc.URLzip) && ~isContainer errMsg = ['No download URL for ', OsType, ': ', PlugName '']; return; end @@ -1869,55 +1871,66 @@ function Configure(PlugDesc) if ~isempty(LogoFile) bst_progress('setimage', LogoFile); end - % Get package file format - if strcmpi(PlugDesc.URLzip(end-3:end), '.zip') - pkgFormat = 'zip'; - elseif strcmpi(PlugDesc.URLzip(end-6:end), '.tar.gz') || strcmpi(PlugDesc.URLzip(end-3:end), '.tgz') - pkgFormat = 'tgz'; + % Code plugins + if ~isContainer + % Get package file format + if strcmpi(PlugDesc.URLzip(end-3:end), '.zip') + pkgFormat = 'zip'; + elseif strcmpi(PlugDesc.URLzip(end-6:end), '.tar.gz') || strcmpi(PlugDesc.URLzip(end-3:end), '.tgz') + pkgFormat = 'tgz'; + else + disp('BST> Could not guess file format, trying ZIP...'); + pkgFormat = 'zip'; + end + % Download file + pkgFile = bst_fullfile(PlugPath, ['plugin.' pkgFormat]); + disp(['BST> Downloading URL : ' PlugDesc.URLzip]); + disp(['BST> Saving to file : ' pkgFile]); + errMsg = gui_brainstorm('DownloadFile', PlugDesc.URLzip, pkgFile, ['Download plugin: ' PlugName], LogoFile); + % If file was not downloaded correctly + if ~isempty(errMsg) + errMsg = ['Impossible to download ' PlugName ' automatically:' 10 errMsg]; + if ~isCompiled + errMsg = [errMsg 10 10 ... + 'Alternative download solution:' 10 ... + '1) Copy the URL below from the Matlab command window: ' 10 ... + ' ' PlugDesc.URLzip 10 ... + '2) Paste it in a web browser' 10 ... + '3) Save the file and unzip it' 10 ... + '4) Add to the Matlab path the folder containing ' PlugDesc.TestFile '.']; + end + bst_progress('removeimage'); + return; + end + % Update progress bar + bst_progress('text', ['Installing plugin: ' PlugName '...']); + if ~isempty(LogoFile) + bst_progress('setimage', LogoFile); + end + % Unzip file + switch (pkgFormat) + case 'zip' + bst_unzip(pkgFile, PlugPath); + case 'tgz' + if ispc + untar(pkgFile, PlugPath); + else + curdir = pwd; + cd(PlugPath); + system(['tar -xf ' pkgFile]); + cd(curdir); + end + end + file_delete(pkgFile, 1, 3); else - disp('BST> Could not guess file format, trying ZIP...'); - pkgFormat = 'zip'; - end - % Download file - pkgFile = bst_fullfile(PlugPath, ['plugin.' pkgFormat]); - disp(['BST> Downloading URL : ' PlugDesc.URLzip]); - disp(['BST> Saving to file : ' pkgFile]); - errMsg = gui_brainstorm('DownloadFile', PlugDesc.URLzip, pkgFile, ['Download plugin: ' PlugName], LogoFile); - % If file was not downloaded correctly - if ~isempty(errMsg) - errMsg = ['Impossible to download ' PlugName ' automatically:' 10 errMsg]; - if ~isCompiled - errMsg = [errMsg 10 10 ... - 'Alternative download solution:' 10 ... - '1) Copy the URL below from the Matlab command window: ' 10 ... - ' ' PlugDesc.URLzip 10 ... - '2) Paste it in a web browser' 10 ... - '3) Save the file and unzip it' 10 ... - '4) Add to the Matlab path the folder containing ' PlugDesc.TestFile '.']; + % Import container image in container engine + [errMsg, imageSha] = bst_containers('ImportImage', PlugDesc.ImageSource, ['brainstorm_' PlugDesc.Name]); + if ~isempty(errMsg) + bst_progress('removeimage'); + return end - bst_progress('removeimage'); - return; + PlugDesc.ImageSha = imageSha; end - % Update progress bar - bst_progress('text', ['Installing plugin: ' PlugName '...']); - if ~isempty(LogoFile) - bst_progress('setimage', LogoFile); - end - % Unzip file - switch (pkgFormat) - case 'zip' - bst_unzip(pkgFile, PlugPath); - case 'tgz' - if ispc - untar(pkgFile, PlugPath); - else - curdir = pwd; - cd(PlugPath); - system(['tar -xf ' pkgFile]); - cd(curdir); - end - end - file_delete(pkgFile, 1, 3); % === SAVE PLUGIN.MAT === PlugDesc.Path = PlugPath; @@ -2162,6 +2175,8 @@ function Configure(PlugDesc) errMsg = ['Plugin ' PlugName ' is not installed.']; return; end + % Check if plugin is a container + isContainer = IsContainer(PlugDesc); % === USER CONFIRMATION === if isInteractive @@ -2209,6 +2224,14 @@ function Configure(PlugDesc) end quit('force'); end + % Remove image from container engine + if isContainer && ~isempty(PlugDesc.ImageSha) + errMsg = bst_containers('RemoveImage', ['brainstorm_' PlugDesc.Name], 1); + if ~isempty(errMsg) + return + end + end + % === CALLBACK: POST-UNINSTALL === [isOk, errMsg] = ExecuteCallback(PlugDesc, 'UninstalledFcn'); @@ -2334,6 +2357,8 @@ function Configure(PlugDesc) errMsg = ['Plugin ', PlugDesc.Name ' is not supported on Apple silicon yet.']; return; end + % Check if plugin is a container + isContainer = IsContainer(PlugDesc); % Minimum Matlab version if ~isempty(PlugDesc.MinMatlabVer) && (PlugDesc.MinMatlabVer > 0) && (bst_get('MatlabVersion') < PlugDesc.MinMatlabVer) strMinVer = sprintf('%d.%d', ceil(PlugDesc.MinMatlabVer / 100), mod(PlugDesc.MinMatlabVer, 100)); @@ -2457,6 +2482,48 @@ function Configure(PlugDesc) else PlugHomeDir = PlugPath; end + % Run container if image was properly imported + if isContainer + PlugDesc = GetInstalled(PlugDesc); + imageName = ['brainstorm_' PlugDesc.Name]; + if ~isempty(PlugDesc.ImageSha) + % Get available images in container engine + [errMsg, imageList] = bst_containers('GetImages'); + if ~isempty(errMsg) + return + end + % Check that image is imported in container engine + isImported = 0; + if ~isempty(imageList) + iImageSha = strcmpi(imageList(:,2), PlugDesc.ImageSha); + isImported = any(strncmpi(imageList(iImageSha,1), imageName, length(imageName))); + end + if isImported + % Check if container exist + [~, containerInfo] = bst_containers('GetContainerInfo', ['bst_' PlugDesc.Name]); + % Run container + if ~containerInfo.isRunning + % Remove container + if ~isempty(containerInfo.name) + bst_containers('StopContainer', containerInfo.name, 1); + end + % Get tmp dir to bind container + TmpDir = bst_get('BrainstormTmpDir', 0, PlugDesc.Name); + volumes = {TmpDir, '/data'}; + % Run container as daemon + errMsg = bst_containers('RunContainer', ['bst_' PlugDesc.Name], PlugDesc.ImageSha, volumes, 1); + if ~isempty(errMsg) + return + end + end + else + % Uninstall container plugin + Uninstall(PlugDesc.Name, 0, 0); + errMsg = ['Reinstall plugin ' PlugDesc.Name '.' 10 10 'Missing container image: ' imageName 10 'SHA: ' PlugDesc.ImageSha]; + return + end + end + end % Do not modify path in compiled mode isCompiled = bst_iscompiled(); if ~isCompiled @@ -2582,6 +2649,8 @@ function Configure(PlugDesc) if ~isempty(errMsg) return; end + % Check if plugin is a container + isContainer = IsContainer(PlugDesc); % === PROCESS DEPENDENCIES === % Unload dependent plugins @@ -2606,6 +2675,21 @@ function Configure(PlugDesc) end end end + % Stop container + if isContainer + % Retrieve info of container + [errMsg, containerInfo] = bst_containers('GetContainerInfo', ['bst_' PlugDesc.Name]); + if isempty(errMsg) + errMsg = bst_containers('StopContainer', ['bst_' PlugDesc.Name], 1); + end + if ~isempty(errMsg) + return + end + % Delete temporary files + for iVolume = 1 : size(containerInfo.volumes, 1) + file_delete(containerInfo.volumes{iVolume,1}, 1, 1); + end + end end % === TEST FUNCTION === @@ -2893,6 +2977,8 @@ function Configure(PlugDesc) if isCompiled && (Plug.CompiledStatus == 0) continue; end + % Check if plugin is a container + isContainer = IsContainer(Plug); % === Add menus for each plugin === % One menu per plugin ij = length(j) + 1; @@ -2925,16 +3011,27 @@ function Configure(PlugDesc) % Main menu j(ij).menu = gui_component('Menu', jParent, [], Plug.Name, [], [], [], fontSize); % Version - j(ij).version = gui_component('MenuItem', j(ij).menu, [], 'Version', [], [], [], fontSize); + iconVersion = []; + if isContainer + iconVersion = IconLoader.ICON_OBJECT; + end + j(ij).version = gui_component('MenuItem', j(ij).menu, [], 'Version', iconVersion, [], [], fontSize); j(ij).versep = java_create('javax.swing.JSeparator'); j(ij).menu.add(j(ij).versep); % Install j(ij).install = gui_component('MenuItem', j(ij).menu, [], 'Install', IconLoader.ICON_DOWNLOAD, [], @(h,ev)InstallInteractive(Plug.Name), fontSize); + if isContainer + j(ij).install.setText('Import image'); + end % Update j(ij).update = gui_component('MenuItem', j(ij).menu, [], 'Update', IconLoader.ICON_RELOAD, [], @(h,ev)UpdateInteractive(Plug.Name), fontSize); + j(ij).update.setVisible(~isContainer); % Uninstall j(ij).uninstall = gui_component('MenuItem', j(ij).menu, [], 'Uninstall', IconLoader.ICON_DELETE, [], @(h,ev)UninstallInteractive(Plug.Name), fontSize); j(ij).menu.addSeparator(); + if isContainer + j(ij).install.setText('Remove image'); + end % Custom install j(ij).custom = gui_component('Menu', j(ij).menu, [], 'Custom install', IconLoader.ICON_FOLDER_OPEN, [], [], fontSize); j(ij).customset = gui_component('MenuItem', j(ij).custom, [], 'Select installation folder', [], [], @(h,ev)SetCustomPath(Plug.Name), fontSize); @@ -2942,10 +3039,17 @@ function Configure(PlugDesc) j(ij).custompath.setEnabled(0); j(ij).custom.addSeparator(); j(ij).customdel = gui_component('MenuItem', j(ij).custom, [], 'Ignore local installation', [], [], @(h,ev)SetCustomPath(Plug.Name, 0), fontSize); - j(ij).menu.addSeparator(); + j(ij).custom.setVisible(~isContainer); + if ~isContainer + j(ij).menu.addSeparator(); + end % Load j(ij).load = gui_component('MenuItem', j(ij).menu, [], 'Load', IconLoader.ICON_GOOD, [], @(h,ev)LoadInteractive(Plug.Name), fontSize); j(ij).unload = gui_component('MenuItem', j(ij).menu, [], 'Unload', IconLoader.ICON_BAD, [], @(h,ev)UnloadInteractive(Plug.Name), fontSize); + if isContainer + j(ij).load.setText('Run container (as daemon)'); + j(ij).unload.setText('Stop container'); + end j(ij).menu.addSeparator(); % Website j(ij).web = gui_component('MenuItem', j(ij).menu, [], 'Website', IconLoader.ICON_EXPLORER, [], @(h,ev)web(Plug.URLinfo, '-browser'), fontSize); @@ -3044,6 +3148,7 @@ function MenuUpdate(jMenu, fontSize) end isLoaded = isInstalled && Plug.isLoaded; isManaged = isInstalled && Plug.isManaged; + isContainer = IsContainer(Plug); % Compiled included: no submenus if isCompiled && (PlugRef.CompiledStatus == 2) j.menu.setEnabled(1); @@ -3055,7 +3160,7 @@ function MenuUpdate(jMenu, fontSize) % Otherwise: all available else % Main menu: Available/Not available - j.menu.setEnabled(isInstalled || ~isempty(Plug.URLzip)); + j.menu.setEnabled(isInstalled || ~isempty(Plug.URLzip) || isContainer); % Current version if ~isInstalled j.version.setText('Not installed'); @@ -3094,10 +3199,14 @@ function MenuUpdate(jMenu, fontSize) end % Install j.install.setEnabled(~isInstalled); + InstallText = 'Install'; + if isContainer + InstallText = 'Import image'; + end if ~isInstalled && ~isempty(PlugRef.Version) && ischar(PlugRef.Version) - j.install.setText(['Install    (' PlugRef.Version ')']) + j.install.setText(['' InstallText '    (' PlugRef.Version ')']) else - j.install.setText('Install'); + j.install.setText(InstallText); end % Update j.update.setEnabled(isManaged); @@ -3474,6 +3583,20 @@ function SetProgressLogo(PlugDesc) pluginNames = { 'duneuro', 'mcxlab-cuda'}; end + +%% ===== IS CONTAINER PLUGIN ===== +% Check if plugin is a container +function isContainer = IsContainer(PlugDesc) + isContainer = 0; + if ischar(PlugDesc) + PlugDesc = GetDescription(PlugDesc); + end + if isempty(PlugDesc.URLzip) && ~isempty(PlugDesc.ImageSource) + isContainer = 1; + end +end + + %% ===== MATCH STRING EDGES ===== % Check if a string 'strA' starts (or ends) with string B function result = strMatchEdge(a, b, edge) diff --git a/toolbox/core/bst_set.m b/toolbox/core/bst_set.m index 49c76f2387..afdea60dd4 100644 --- a/toolbox/core/bst_set.m +++ b/toolbox/core/bst_set.m @@ -277,7 +277,7 @@ function bst_set( varargin ) 'StatThreshOptions', 'ContactSheetOptions', 'ProcessOptions', 'BugReportOptions', 'DefaultSurfaceDisplay', ... 'MagneticExtrapOptions', 'MriOptions', 'ConnectGraphOptions', 'NodelistOptions', 'IgnoreMemoryWarnings', 'SystemCopy', ... 'TimefreqOptions_morlet', 'TimefreqOptions_hilbert', 'TimefreqOptions_fft', 'TimefreqOptions_psd', 'TimefreqOptions_stft', 'TimefreqOptions_plv', ... - 'OpenMEEGOptions', 'DuneuroOptions','NIRSTORMOptions', 'DigitizeOptions', 'PcaOptions', 'CustomColormaps', 'PluginCustomPath', 'BrainSuiteDir', 'PythonExe', ... + 'OpenMEEGOptions', 'DuneuroOptions','NIRSTORMOptions', 'DigitizeOptions', 'PcaOptions', 'CustomColormaps', 'PluginCustomPath', 'BrainSuiteDir', 'PythonExe', 'ContainerEngine', ... 'GridOptions_headmodel', 'GridOptions_dipfit', 'LastPsdDisplayFunction', 'LastTfDisplayFunction', 'KlustersExecutable', 'ExportBidsOptions', 'ShowHiddenFiles'} GlobalData.Preferences.(contextName) = contextValue; diff --git a/toolbox/db/db_template.m b/toolbox/db/db_template.m index d5ac6a20e7..ab9d933f78 100644 --- a/toolbox/db/db_template.m +++ b/toolbox/db/db_template.m @@ -1220,13 +1220,15 @@ 'UnloadedFcn', [], ... % String to eval or function handle to call after unloading the plugin 'DeleteFiles', [], ... % Cell-array of files to delete after unzipping the plugin package (path relative to the plugin folder) 'DeleteFilesBin',[], ... % Cell-array of files to delete before compiling Brainstorm, to avoid including them in the binary distribution (path relative to the plugin folder) + 'ImageSource', '', ... % String with location of container image: registry reference, path or download URL ... % Set when installing or loading the plugin 'InstallDate', '', ... % Installation date 'SubFolder', '', ... % If all the code is in a subfolder: detect this at installation time 'Path', [], ... % Set at runtime: Installation path for this plugin 'Processes', [], ... % List of process functions to be added to the pipeline manager 'isLoaded', 0, ... % Set at runtime: 0=Not loaded, 1=Loaded (folder and specific subfolders added to Matlab path) - 'isManaged', 0); % Set at runtime: 0=Installed by the user, 1=Installed automatically by Brainstorm + 'isManaged', 0, ... % Set at runtime: 0=Installed by the user, 1=Installed automatically by Brainstorm + 'ImageSha', ''); % String with SHA for container ImageSource template.LoadFolders = {}; template.UnloadPlugs = {}; template.RequiredPlugs = {}; diff --git a/toolbox/gui/panel_options.m b/toolbox/gui/panel_options.m index dad8613300..78bd67d571 100644 --- a/toolbox/gui/panel_options.m +++ b/toolbox/gui/panel_options.m @@ -147,6 +147,16 @@ jBlockSize.setToolTipText(blockSizeTooltip); jPanelRight.add('br hfill', jPanelProc); + % ===== RIGHT: CONTAINER ENGINE ===== + [~, tmp] = bst_get('ContainerEngine'); + jPanelContainers = gui_river([5 5], [0 15 15 15], 'Container engine'); + jContainerLabel = gui_component('Label', jPanelContainers, [], 'Container engine for container-based plugins: ', [], [], []); + jContainerCombo = gui_component('Combobox', jPanelContainers, 'tab', [], {tmp}, [], [], []); + containerTooltip = 'Default: "auto-detect"'; + jContainerLabel.setToolTipText(containerTooltip); + jContainerCombo.setToolTipText(containerTooltip); + jPanelRight.add('br hfill', jPanelContainers); + % ===== RIGHT: RESET ===== if (GlobalData.Program.GuiLevel == 1) jPanelReset = gui_river([5 5], [0 15 15 15], 'Reset Brainstorm'); @@ -241,6 +251,10 @@ function LoadOptions() jCheckUseSigProc.setSelected(bst_get('UseSigProcToolbox')); processOptions = bst_get('ProcessOptions'); jBlockSize.setText(num2str(processOptions.MaxBlockSize * 8 / 1024 / 1024)); + % Container engine + [containerEngine, containerEngines] = bst_get('ContainerEngine'); + iSel = find(strcmpi(containerEngine, containerEngines), 1, 'first') - 1; + jContainerCombo.setSelectedIndex(iSel); end @@ -387,6 +401,11 @@ function SaveOptions() processOptions.MaxBlockSize = blockSize * 1024 * 1024 / 8; % Mb to bytes bst_set('ProcessOptions', processOptions); end + + % ===== CONTAINER ENGINE ===== + bst_set('ContainerEngine', char(jContainerCombo.getSelectedItem())); + + % Stop applying preferences bst_progress('stop'); % If the scaling was changed: Restart brainstorm