airavata-local-agent/main/background.js (631 lines of code) (raw):

/***************************************************************** * * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. * * *****************************************************************/ import path from 'path'; import { app, ipcMain, dialog, session, Menu, shell } from 'electron'; const url = require('node:url'); import serve from 'electron-serve'; import { createWindow } from './helpers'; const fs = require('fs'); import log from 'electron-log/main'; import Store from 'electron-store'; const isProd = process.env.NODE_ENV === 'production'; let hasQuit = false; if (isProd) { serve({ directory: 'app' }); } else { app.setPath('userData', `${app.getPath('userData')} (development)`); } let mainWindow; const TOKEN_FILE = '~/csagent/token/keys.json'; let BASE_URL = 'this value will be replaced'; // ----- OUR CUSTOM FUNCTIONS ----- if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient('csagent', process.execPath, [path.resolve(process.argv[1])]); } } else { app.setAsDefaultProtocolClient('csagent'); } const openLoginCallback = async (url) => { log.info("Opening login callback"); const rawCode = /code=([^&]*)/.exec(url) || null; const code = (rawCode && rawCode.length > 1) ? rawCode[1] : null; if (isProd) { await mainWindow.loadURL(`app://./login-callback?code=${code}`); } else { const port = process.argv[2]; await mainWindow.loadURL(`http://localhost:${port}/login-callback?code=${code}`); } }; const getToken = async (url) => { const rawCode = /code=([^&]*)/.exec(url) || null; const code = (rawCode && rawCode.length > 1) ? rawCode[1] : null; if (code) { const resp = await fetch(`${BASE_URL}/auth/get-token-from-code/?code=${code}&isProd=${isProd}`); const data = await resp.json(); return data; } else { return null; } }; // ----- END OF OUR CUSTOM FUNCTIONS ----- const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', (event, commandLine, workingDirectory, additionalData) => { // catches windows and linux log.error("In second-instance"); if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } log.info("Command line: ", commandLine); log.info('Additional data: ', additionalData); const url = commandLine.pop(); log.info("URL: ", url); openLoginCallback(url); }); app.on('open-url', async (event, url) => { // catches mac log.info("In open-url"); openLoginCallback(url); }); // Create mainWindow, load the rest of the app, etc... app.whenReady().then(async () => { mainWindow = createWindow('main', { width: 1700, height: 1000, autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), webSecurity: false, }, 'node-integration': true, }); log.info("App is now ready"); app.commandLine.appendSwitch('ignore-certificate-errors'); session.defaultSession.clearStorageData([], (data) => { log.info("Cleared storage data", data); }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { // open external URLs in browser, not in the app shell.openExternal(url); return { action: 'deny' }; }); mainWindow.on('close', function (e) { var choice = dialog.showMessageBoxSync(this, { type: 'question', buttons: ['Yes', 'No'], title: 'Confirm', message: 'Are you sure you want to close the local agent?' }); if (choice == 1) { e.preventDefault(); } else { hasQuit = true; } }); if (isProd) { await mainWindow.loadURL('app://./home'); // globalShortcut.register("CommandOrControl+R", () => { // log.info("CommandOrControl+R is pressed: Shortcut Disabled"); // }); // globalShortcut.register("F5", () => { // log.info("F5 is pressed: Shortcut Disabled"); // }); // mainWindow.removeMenu(); // Menu.setApplicationMenu(Menu.buildFromTemplate([])); } else { const port = process.argv[2]; await mainWindow.loadURL(`http://localhost:${port}/home`); mainWindow.webContents.openDevTools(); } }); } app.on('window-all-closed', () => { app.quit(); }); ipcMain.on('get-version-number', (event) => { log.info(`Cybershuttle Local Agent version: ${app.getVersion()}`); event.sender.send('version-number', app.getVersion()); }); ipcMain.on('message', async (event, arg) => { event.reply('message', `${arg} World!`); }); ipcMain.on('open-default-browser', (event, url) => { shell.openExternal(url); }); ipcMain.on('ci-logon-logout', (event) => { log.warn('logging out'); session.defaultSession.clearStorageData([], (data) => { log.info("Cleared storage data", data); }); }); ipcMain.on('ci-logon-login', async (event) => { log.warn("Logging in with CI logon"); var authWindow = createWindow('authWindow', { width: 1200, height: 800, show: false, 'node-integration': false, 'web-security': false }); authWindow.loadURL(`${BASE_URL}/auth/redirect_login/cilogon/`); authWindow.show(); authWindow.webContents.on('will-redirect', async (e, url) => { if (url.startsWith(`${BASE_URL}/auth/callback/`)) { // hitUrl = true setTimeout(async () => { const data = await getToken(url); log.info("Got the token: ", data); event.sender.send('ci-logon-success', data); writeFile(event, TOKEN_FILE, JSON.stringify(data)); authWindow.close(); }, 2000); authWindow.hide(); } }); }); let associatedIDToWindow = {}; let counter = 0; function printKeys(obj) { // only show the keys log.info(Object.keys(obj)); } const removeExpWindow = (event, associatedId) => { log.info("Removing the window with id: ", associatedId); try { associatedIDToWindow[associatedId].removeAllListeners('close'); associatedIDToWindow[associatedId].close(); delete associatedIDToWindow[associatedId]; } catch (e) { log.error("Window doesn't exist with id: ", associatedId); } }; const createExpWindow = (event, url, associatedId) => { log.info("Showing the window with url: ", url, " and associatedId: ", associatedId); if (associatedIDToWindow[associatedId]) { log.info("Window already exists with id: ", associatedId, " not creating a new one."); return; } counter++; let window = createWindow(`jnWindow-${counter}`, { width: 1200, height: 800, show: false, webPreferences: { allowDisplayingInsecureContent: true, allowRunningInsecureContent: true } }); log.info("Window created with id: ", associatedId, " and counter: ", counter); associatedIDToWindow[associatedId] = window; window.loadURL(url); window.show(); printKeys(associatedIDToWindow); window.on('close', () => { log.info("Window has been closed: ", associatedId); associatedIDToWindow[associatedId].removeAllListeners('close'); delete associatedIDToWindow[associatedId]; printKeys(associatedIDToWindow); event.sender.send('window-has-been-closed', associatedId); }); }; ipcMain.on('show-window', createExpWindow); ipcMain.on('close-window', (event, associatedId) => { try { log.info("Closing the window with id: ", associatedId); associatedIDToWindow[associatedId].removeAllListeners('close'); associatedIDToWindow[associatedId].close(); delete associatedIDToWindow[associatedId]; } catch (e) { log.error("Window doesn't exist with id: ", associatedId); } }); ipcMain.on('is-prod', (event) => { event.sender.send('is-prod-reply', isProd); }); ipcMain.on('get-csagent-path', (event) => { const homedir = require('os').homedir(); const userPath = path.join(homedir, 'csagent'); event.sender.send('got-csagent-path', userPath); }); async function getAccessTokenFromRefreshToken(refreshToken) { const respForRefresh = await fetch(`${BASE_URL}/auth/get-token-from-refresh-token?refresh_token=${refreshToken}`); if (!respForRefresh.ok) { // throw new Error("Failed to fetch new access token (refresh token)"); return null; } const data = await respForRefresh.json(); return data; }; async function checkAccessToken(event, url, options, refreshToken) { log.info("Checking if token exists"); let resp = await fetch(url, options); if (!resp.ok) { log.warn("Access token is invalid, trying to get a new one"); const data = await getAccessTokenFromRefreshToken(refreshToken); if (!data.access_token || !data.refresh_token) { return null; } writeFile(event, TOKEN_FILE, JSON.stringify(data)); }; log.info("Access token is valid"); return resp; } ipcMain.on('ensure-token', (event) => { // create an interval to check if the token in TOKEN_FILE exists log.info("Starting loop to ensure token exists"); const interval = setInterval(async () => { const data = readFile(TOKEN_FILE); if (data) { const json = JSON.parse(data); const accessToken = json.access_token; const refreshToken = json.refresh_token; const url = `${BASE_URL}/api/`; const options = { method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}` } }; try { const resp = await checkAccessToken(event, url, options, refreshToken); if (resp) { event.sender.send('ensure-token-result', true); } else { log.error("Could not get access token from refresh token, stopping loop."); clearInterval(interval); event.sender.send('ensure-token-result', false); } } catch (err) { log.error("Error: ", err); } } else { log.error("Token doesn't exist, not checking"); return; } }, 60000); // every minute }); // ----------------- DOCKER ----------------- var Docker = require('dockerode'); var docker = new Docker(); //defaults to above if env variables are not used let portsCache = {}; const showWindowWhenReady = (event, id, port) => { let url = `http://localhost:${port}/lab`; let interval = setInterval(async () => { // check if the container is still running let container = await docker.getContainer(id); let inspected = await container.inspect(); if (inspected.State.Status !== "running") { log.info("Container is not running, removing the window with id: ", id, " and clearing the interval"); removeExpWindow(event, id); clearInterval(interval); return; } else { fetch(url) .then((response) => { if (response.status === 200) { log.info("Got a 200 response from the popup, showing the window"); createExpWindow(event, url, id); clearInterval(interval); } }) .catch((error) => { log.error("Error: ", error); }); } }, 5000); }; const getContainers = (event) => { log.info("Getting running containers"); docker.listContainers({ all: true }, function (err, containers) { // make sure everything in associatedIDToWindow is a container that is running. if not, remove it for (let key in associatedIDToWindow) { for (let i = 0; i < containers.length; i++) { if (containers[i].Id === key) { if (containers[i].State !== "running") { log.info("Container is not running, removing the window with id: ", key); removeExpWindow(event, key); break; } } } } if (!event.sender.isDestroyed()) { event.sender.send('got-containers', containers); } }); }; const pullDockerImage = (event, imageName, callback) => { log.info("Pulling docker image: ", imageName); const onProgress = function (obj) { log.info("Progress: ", obj); event.sender.send('docker-pull-progress', obj); }; const onFinished = function (err, output) { log.info("Finished: ", output); event.sender.send('docker-pull-finished', output); if (callback) { callback(); } }; docker.pull(imageName, function (err, stream) { docker.modem.followProgress(stream, onFinished, onProgress); }); }; const doesImageExist = async (imageName) => { const image = docker.getImage(imageName); console.log(image); try { const data = await image.inspect(); // will throw an error if the image doesn't exist return true; } catch (e) { console.log(e); return false; } }; ipcMain.on('start-container', (event, containerId) => { log.info("Starting the container with containerId: ", containerId); let container = docker.getContainer(containerId); container.start(async function (err, data) { log.info("Starting container: ", containerId); if (err) { event.sender.send('container-started', containerId, err.message); } else { let error = ""; try { let cont = await container.inspect(); let port = cont.NetworkSettings.Ports['8888/tcp'][0].HostPort; showWindowWhenReady(event, containerId, port); } catch (e) { log.error("Error: ", e); err = e.message; } event.sender.send('container-started', containerId, error); } }); }); ipcMain.on('show-window-from-id', (event, containerId) => { log.info("Showing the window with containerId: ", containerId); let container = docker.getContainer(containerId); container.inspect(async function (err, data) { if (err) { log.error("Error inspecting container: ", err); } else { let port = data.NetworkSettings.Ports['8888/tcp'][0].HostPort; let url = `http://localhost:${port}/lab`; // ping the URL first, if it's not up, don't show the window fetch(url) .then((response) => { if (response.status === 200) { log.info("Got a 200 response from the popup, showing the window"); createExpWindow(event, url, containerId); } }) .catch((error) => { log.error("Error: ", error); }); } }); }); ipcMain.on('stop-container', (event, containerId) => { log.info("Stopping the container with containerId: ", containerId); let container = docker.getContainer(containerId); container.stop(function (err, data) { console.log("Container stopped: ", containerId); event.sender.send('container-stopped', containerId); }); }); ipcMain.on('start-notebook', async (event, createOptions) => { const imageName = "airavata/airavata-jupyter-lab"; log.info("Starting the notebook with imageName: ", imageName); const startNotebook = () => { log.info("Starting the notebook"); docker.run(imageName, [], null, createOptions, function (err, data, container) { if (err) { console.error("Error starting the notebook: ", err); } }) .on('container', function (container) { log.info("Container created: ", container.id); let err = ""; try { showWindowWhenReady(event, container.id, createOptions.HostConfig.PortBindings['8888/tcp'][0].HostPort); } catch (e) { console.log(e); err = e; } event.sender.send('notebook-started', container.id, err); }); }; try { pullDockerImage(event, imageName, startNotebook); } catch (e) { console.log(e); } }); /* 1. share binaries w/Eroma to test - if docker is not running, show that message on the home page - do authentication before showing docker page - make this auth in default browser, need create cs:// url for login? with token, parse token - https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app */ ipcMain.on("get-containers", getContainers); ipcMain.on('inspect-container', (event, containerId) => { log.info("Inspecting the container with containerId: ", containerId); let container = docker.getContainer(containerId); container.inspect(function (err, data) { event.sender.send('container-inspected', data); }); }); ipcMain.on('pause-container', (event, containerId) => { log.info("Pausing the container with containerId: ", containerId); let container = docker.getContainer(containerId); container.pause(function (err, data) { console.log("Container paused: ", containerId); event.sender.send('container-paused', containerId); }); }); ipcMain.on('unpause-container', (event, containerId) => { log.info("Unpausing the container with containerId: ", containerId); let container = docker.getContainer(containerId); container.unpause(function (err, data) { console.log("Container unpaused: ", containerId); event.sender.send('container-unpaused', containerId); }); }); ipcMain.on('remove-container', (event, containerId) => { log.info("Removing the container with containerId: ", containerId); let container = docker.getContainer(containerId); container.remove(function (err, data) { console.log("Container removed: ", containerId); event.sender.send('container-removed', containerId); }); }); ipcMain.on('rename-container', (event, containerId, newName) => { log.info("Renaming the container with containerId: ", containerId, " to ", newName); let container = docker.getContainer(containerId); container.rename({ name: newName }, function (err, data) { console.log("Container renamed: ", containerId); event.sender.send('container-renamed', containerId, newName); }); }); ipcMain.on("choose-filepath", async (event) => { const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); if (!result.canceled) { event.sender.send('filepath-chosen', result.filePaths[0]); } }); ipcMain.on('get-container-ports', async (event, containers) => { log.info("Getting container ports"); let ports = {}; // array of objects with containerId and port for (let i = 0; i < containers.length; i++) { if (portsCache[containers[i].Id]) { ports[containers[i].Id] = portsCache[containers[i].Id]; } else { log.warn("Not in cache: ", containers[i].Id, " getting from docker"); const container = docker.getContainer(containers[i].Id); const data = await container.inspect(); let containerPorts = Object.keys(data.HostConfig.PortBindings); let tempMappings = []; for (let j = 0; j < containerPorts.length; j++) { let tempMapping = {}; let hostPort = data.HostConfig.PortBindings[containerPorts[j]][0].HostPort; tempMapping.containerPort = containerPorts[j]; tempMapping.hostPort = hostPort; tempMappings.push(tempMapping); } ports[containers[i].Id] = tempMappings; portsCache[containers[i].Id] = tempMappings; } } if (!event.sender.isDestroyed()) { event.sender.send('got-container-ports', ports); } /* Ports looks like: { "containerId": [ { "containerPort": "8888/tcp", "hostPort": "6080" } ] } */ }); ipcMain.on('docker-ping', (event) => { log.info("Pinging docker"); docker.ping(function (err, data) { log.info("Docker pinged: ", data); if (!event.sender.isDestroyed()) { event.sender.send('docker-pinged', data); } else { log.error("Sender is destroyed"); } }); }); ipcMain.on('get-should-show-runs', (event, allContainers) => { // return a list same length as runningContainers with true or false, // true if container is running and is not in associatedIdToWindow let shouldShowRuns = {}; for (let i = 0; i < allContainers.length; i++) { if (allContainers[i].State === "running" && !associatedIDToWindow[allContainers[i].Id]) { shouldShowRuns[allContainers[i].Id] = true; } else { shouldShowRuns[allContainers[i].Id] = false; } } if (!event.sender.isDestroyed()) { event.sender.send('got-should-show-runs', shouldShowRuns); } }); // ----------------- IMAGES ----------------- ipcMain.on('get-all-images', (event) => { log.info("Getting all images"); docker.listImages(function (err, images) { event.sender.send('got-all-images', images); }); }); ipcMain.on('inspect-image', (event, imageId) => { log.info("Inspecting the image with imageId: ", imageId); let image = docker.getImage(imageId); image.inspect(function (err, data) { event.sender.send('image-inspected', data); }); }); // ----------------- TOKEN AUTH ----------------- function createIfNotExists(path) { if (!fs.existsSync(path)) { fs.writeFileSync(path, '', 'utf8'); } } const readFile = (userPath) => { log.info("Reading file: ", userPath); try { if (userPath.startsWith("~")) { const homedir = require('os').homedir(); userPath = path.join(homedir, userPath.substring(1)); } const data = fs.readFileSync(userPath, 'utf8'); return data; } catch (e) { return null; } }; ipcMain.on('read-file', (event) => { const data = readFile(TOKEN_FILE); event.sender.send('file-read', data); }); const writeFile = async (event, userPath, data) => { log.info("Writing to file: ", userPath, " with data: ", data); if (userPath.startsWith("~")) { const homedir = require('os').homedir(); // substring until the file at the end to create mkdirs const dirPath = userPath.substring(1, userPath.lastIndexOf('/')); fs.mkdirSync(path.join(homedir, dirPath), { recursive: true }); fs.writeFileSync(path.join(homedir, userPath.substring(1)), data); log.info("File written"); event.sender.send('file-written', data); } else { createIfNotExists(userPath); fs.writeFile(userPath, data, (err) => { if (err) { log.error("Error writing file: ", err); event.sender.send('file-written', err); } else { log.info("File written"); event.sender.send('file-written', data); } }); } }; ipcMain.on('write-file', writeFile); /* GATEWAY PINGING LOGIC */ const store = new Store(); const randomString = (length) => { const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; let result = ''; for (let i = length; i > 0; --i) { result += chars[Math.floor(Math.random() * chars.length)]; } return result; }; const gateways = [ { "id": "mdcyber", "name": "MD Cybershuttle", "gateway": "https://cybershuttle.org", "loginUrl": `https://iam.scigap.org/auth/realms/testdrive/protocol/openid-connect/auth?response_type=code&client_id=pga&redirect_uri=csagent%3A%2F%2Flogin-callback&scope=openid&state=${randomString(15)}&kc_idp_hint=cilogon&idp_alias=cilogon` }, { "id": "aicyber", "name": "AI Cybershuttle", "gateway": "https://ai.cybershuttleadfasfd.org", "loginUrl": `https://google.com` } ]; log.info("Gateways: ", gateways); let CURRENT_GATEWAY = store.get('stored-gateway'); log.info("CURRENT_GATEWAY (before): ", CURRENT_GATEWAY); if (typeof CURRENT_GATEWAY === "undefined" || CURRENT_GATEWAY === null) { CURRENT_GATEWAY = "mdcyber"; } log.info("CURRENT_GATEWAY (after): ", CURRENT_GATEWAY); BASE_URL = gateways.find(g => g.id === CURRENT_GATEWAY)?.gateway; log.info("BASE_URL: ", BASE_URL); ipcMain.on('get-all-gateways', (event) => { log.info("Getting all gateways"); event.sender.send('got-gateways', gateways); }); ipcMain.on('get-gateway', (event) => { log.info("Getting the gateway"); event.sender.send('gateway-got', CURRENT_GATEWAY); }); ipcMain.on('set-gateway', (event, gateway) => { log.info("Setting the gateway: ", gateway); CURRENT_GATEWAY = gateway; BASE_URL = gateways.find(g => g.id === CURRENT_GATEWAY).gateway; store.set('stored-gateway', gateway); event.sender.send('gateway-set', gateway); });