diff --git a/README.md b/README.md index 069d61d..e30fa56 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ jupyter labextension list When installing from a published wheel, Node.js and `jlpm` are not required. +## 🔐 Access Tokens on EGI Notebooks + +When APRICOTLab runs inside EGI Notebooks, the deployment wizard tries to obtain an EGI access token automatically and fills the access token field for you. + +You can still edit that field manually before deploying. This is useful when a target site does not support the automatically generated token or requires a different token for that specific site. + ## ✨ IPython Magics for Infrastructure Management The extension provides a set of custom IPython magic commands for interacting with deployed infrastructures: @@ -147,7 +153,7 @@ cd apricotlab pip install -e . # Link the extension to JupyterLab -jupyter labextension develop . --overwrite +jupyter-builder develop . --overwrite # Build the extension jlpm build @@ -178,7 +184,7 @@ jupyter lab build --minimize=False pip uninstall apricot ``` -You should also remove the symlink created with `jupyter labextension develop`. Run: +You should also remove the symlink created with `jupyter-builder develop`. Run: ```bash jupyter labextension list diff --git a/apricot_magics/apricot_magics.py b/apricot_magics/apricot_magics.py index 95e2ba7..8fddba5 100644 --- a/apricot_magics/apricot_magics.py +++ b/apricot_magics/apricot_magics.py @@ -10,6 +10,8 @@ import os import json import sys +import shutil +import re IM_ENDPOINT = "https://im.egi.eu/im" @@ -22,38 +24,53 @@ def __init__(self, shell): self.load_paths() data = self.load_json(self.inf_list_path) - access_token = data.get("access_token") + access_token = data.get("access_token", "") + refresh_token = data.get("refresh_token", "") + self.client = None - if access_token != "": + if access_token: auth = f""" type = InfrastructureManager; token = {access_token} """ - else: - refresh_token = data["refresh_token"] + self.client = IMClient.init_client(IM_ENDPOINT, auth) + elif refresh_token: self.generate_new_access_token(refresh_token) - - self.client = IMClient.init_client(IM_ENDPOINT, auth) + self.initialize_im_client() ######################## # Auxiliar functions # ######################## def load_paths(self): - # Get the absolute path to the current file (apricot_magics.py) - current_dir = Path(__file__).parent + state_dir = Path.home() / "apricotlab_state" + state_dir.mkdir(exist_ok=True) + + for legacy_dir in ( + Path.cwd() / "apricotlab_state", + Path.cwd().parent / "apricotlab_state", + ): + if legacy_dir.exists() and legacy_dir.resolve() != state_dir.resolve(): + for legacy_file in legacy_dir.iterdir(): + target = state_dir / legacy_file.name + if legacy_file.is_file() and not target.exists(): + shutil.copy2(legacy_file, target) + + self.inf_list_path = state_dir / "infrastructuresList.json" + self.deployed_template_path = state_dir / "deployed-template.yaml" + self.authfile_path = state_dir / "authfile" - # Construct the path to the 'resources' folder relative to 'apricot_magics/' - resources_dir = current_dir.parent / "resources" - - self.inf_list_path = resources_dir / "infrastructuresList.json" - self.deployed_template_path = resources_dir / "deployed-template.yaml" - self.authfile_path = resources_dir / "authfile" - - # Check if the files exist if not self.inf_list_path.exists(): - raise FileNotFoundError(f"File not found: {self.inf_list_path}") + self.inf_list_path.write_text( + json.dumps({"refresh_token": "", "infrastructures": []}, indent=4) + ) + if not self.deployed_template_path.exists(): - raise FileNotFoundError(f"File not found: {self.deployed_template_path}") + self.deployed_template_path.touch() + + if not self.authfile_path.exists(): + self.authfile_path.write_text( + "id = im; type = InfrastructureManager; token = \n" + ) def load_json(self, path): """Load a JSON file and handle errors.""" @@ -422,101 +439,80 @@ def apricot_ls(self, line): ) ) - @line_magic - def apricot_info(self, line): - if not line: - return "Usage: `%apricot_info infrastructure-id`\n" - - inf_id = line.split()[0] - + def get_raw_infrastructure_info(self, inf_id): try: self.initialize_im_client() success, inf_info = self.client.getinfo(inf_id) + return list(inf_info) except Exception as e: print(f"Error: {e}") + return None + + @line_magic + def apricot_radl(self, line): + if not line: + return "Usage: `%apricot_radl infrastructure-id`\n" + + inf_id = line.split()[0] + inf_info_items = self.get_raw_infrastructure_info(inf_id) + + if inf_info_items is None: return "Failed" - for item in inf_info: + for item in inf_info_items: print(*item, sep="\n") - # @line_magic - # def apricot_vmls(self, line): + @line_magic + def apricot_info(self, line): if not line: - print("Usage: `%apricot_vmls infrastructure-id`\n") - return "Fail" + return "Usage: `%apricot_info infrastructure-id`\n" inf_id = line.split()[0] + inf_info_items = self.get_raw_infrastructure_info(inf_id) + + if inf_info_items is None: + return "Failed" + vm_info_list = [] - try: - self.initialize_im_client() - success, inf_info = self.client.getinfo(inf_id) + def extract_property(output, names, operators=("=", ">=")): + for name in names: + for operator in operators: + pattern = rf"{re.escape(name)}\s*{re.escape(operator)}\s*'([^']*)'" + match = re.search(pattern, output) + if match: + return match.group(1).split()[0] - except Exception as e: - print(f"Error: {e}") - return "Failed" + pattern = rf"{re.escape(name)}\s*{re.escape(operator)}\s*([^\s\n]+)" + match = re.search(pattern, output) + if match: + return match.group(1).strip("'") + + return "N/A" - for item in inf_info: + for item in inf_info_items: vm_id = item[0] - ( - net_interface_ip, - provider_type, - disk_size, - cpu_count, - memory_size, - gpu_count, - ) = (None, None, None, None, None, None) - - output_string = item[ - 2 - ] # The third element contains the VM details as a string - for line in output_string.split("\n"): - if "net_interface.0.ip =" in line: - net_interface_ip = ( - line.split("= ")[1].strip().replace("'", "").split(" ")[0] - ) - if "provider.type =" in line: - provider_type = ( - line.split("= ")[1].strip().replace("'", "").split(" ")[0] - ) - if "disk.0.size >=" in line: - disk_size = line.split(">= ")[1].strip().strip("'").split(" ")[0] - if "cpu.count =" in line: - cpu_count = line.split("= ")[1].strip().strip("'").split(" ")[0] - if "memory.size =" in line: - memory_size = line.split("= ")[1].strip().strip("'").split(" ")[0] - if "gpu.count >=" in line: - gpu_count = line.split(">= ")[1].strip().strip("'").split(" ")[0] - - start_time = time.time() - while not all( - ( + output_string = item[2] if len(item) > 2 else "" + + vm_info_list.append( + [ vm_id, - net_interface_ip, - provider_type, - disk_size, - cpu_count, - memory_size, - memory_size, - ) - ): # Ensure valid values - if time.time() - start_time > 4: - break - # time.sleep(1) - - # if all((vm_id, net_interface_ip, provider_type, disk_size, cpu_count, memory_size, gpu_count)): - vm_info_list.append( - [ - vm_id, - net_interface_ip, - provider_type, - disk_size, - cpu_count, - memory_size, - gpu_count, - ] - ) + extract_property( + output_string, + ["net_interface.1.ip", "net_interface.0.ip", "node_ip"], + ), + extract_property(output_string, ["provider.type"]), + extract_property(output_string, ["disk.0.size"]), + extract_property(output_string, ["cpu.count"]), + extract_property(output_string, ["memory.size"]), + extract_property(output_string, ["gpu.count"]), + ] + ) + + if not vm_info_list: + print("No VM information found.") + return # Print table print( @@ -644,7 +640,7 @@ def apricot(self, code, cell=None): for line in lines: if len(line) > 0: result = self.apricot(line.strip()) - if result != "Done": + if result not in ("Done", None): print("Execution stopped") return f"Fail on line: '{line.strip()}'" return "Done" @@ -709,7 +705,7 @@ def apricot(self, code, cell=None): self.cleanup_files("key.pem") - return "Done" + return None elif word1 == "list": return self.apricot_ls("") @@ -738,4 +734,4 @@ def load_ipython_extension(ipython): can be loaded via `%load_ext module.path` or be configured to be autoloaded by IPython at startup time. """ - ipython.register_magics(Apricot_Magics) \ No newline at end of file + ipython.register_magics(Apricot_Magics) diff --git a/apricot_tutorial.ipynb b/apricot_tutorial.ipynb index 453ea82..1b61388 100644 --- a/apricot_tutorial.ipynb +++ b/apricot_tutorial.ipynb @@ -31,7 +31,7 @@ "source": [ "## 🔐 Authorization File\n", "\n", - "Before diving in, you’ll need an **authorization file** to authenticate with cloud providers. This file is located at `resources/authfile`: \n", + "Before diving in, you’ll need an **authorization file** to authenticate with cloud providers. This file is located at `apricotlab_state/authfile`: \n", "\n", "- 👉 It's **automatically filled** if you are using predefined recipes used in **Deployment Menu**.\n", "\n", @@ -143,7 +143,7 @@ "id": "f3954ffd", "metadata": {}, "source": [ - "💡 **Tip**: If you've already saved this token before, (so it is saved in `resources/infrastructuresList.json`) — you can run the command without the variable. " + "💡 **Tip**: If you've already saved this token before, (so it is saved in `apricotlab_state/infrastructuresList.json`) — you can run the command without the variable. " ] }, { @@ -348,7 +348,9 @@ "metadata": {}, "source": [ "## 🧠 Get Information About the Infrastructure Nodes\n", - "Explore detailed information about your infrastructure nodes:" + "Explore detailed information about your infrastructure nodes.\n", + "\n", + "Use `%apricot_info` to show a compact table with the VM ID, public IP, provider, disk, CPU, memory and GPU values.\n" ] }, { @@ -365,6 +367,24 @@ "%apricot_info $infrastructure_id" ] }, + { + "cell_type": "markdown", + "id": "2678f0d8", + "metadata": {}, + "source": [ + "If you need to inspect the raw infrastructure description returned by the IM, use `%apricot_radl`. This prints the RADL/raw information directly, which is useful for debugging or checking provider-specific fields.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a36d8b8c", + "metadata": {}, + "outputs": [], + "source": [ + "%apricot_radl $infrastructure_id" + ] + }, { "cell_type": "markdown", "id": "2e80eb5f", diff --git a/package.json b/package.json index d5f4e1a..9ae3522 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "files": [ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", - "src/**/*.{ts,tsx}" + "src/**/*.{ts,tsx}", + "resources/**/*" ], "main": "lib/index.js", "types": "lib/index.d.ts", @@ -31,8 +32,9 @@ "scripts": { "build": "jlpm build:lib && jlpm build:labextension:dev", "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", - "build:labextension": "jupyter labextension build .", - "build:labextension:dev": "jupyter labextension build --development True .", + "build:labextension": "jupyter labextension build . && jlpm copy:resources", + "build:labextension:dev": "jupyter labextension build --development True . && jlpm copy:resources", + "copy:resources": "node -e \"const fs=require('fs');fs.rmSync('apricot/labextension/resources',{recursive:true,force:true});fs.cpSync('resources','apricot/labextension/resources',{recursive:true});\"", "build:lib": "tsc --sourceMap", "build:lib:prod": "tsc", "clean": "jlpm clean:lib", diff --git a/resources/authfile b/resources/authfile deleted file mode 100644 index 99a88bc..0000000 --- a/resources/authfile +++ /dev/null @@ -1 +0,0 @@ -id = im; type = InfrastructureManager; token = \ No newline at end of file diff --git a/resources/deployed-template.yaml b/resources/deployed-template.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/resources/infrastructuresList.json b/resources/infrastructuresList.json deleted file mode 100644 index a777c35..0000000 --- a/resources/infrastructuresList.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "refresh_token": "", - "infrastructures": [] -} diff --git a/slurm-experiment.ipynb b/slurm-experiment.ipynb index 9c9fe72..d096683 100755 --- a/slurm-experiment.ipynb +++ b/slurm-experiment.ipynb @@ -90,7 +90,7 @@ "- Use the **custom recipe** below in **TOSCA** format:\n", "> 🔍 Change both _image_ values with the valid cloud provider image you want to use.\n", "\n", - "> ✍️ You will need to fill your authfile in `resources/authfile` with your IM and cloud credentials if you use the *magic commands* to deploy the cluster." + "> ✍️ You will need to fill your authfile in `apricotlab_state/authfile` with your IM and cloud credentials if you use the *magic commands* to deploy the cluster." ] }, { diff --git a/src/deploymentMenu.ts b/src/deploymentMenu.ts index dcf3931..7ae1b1f 100644 --- a/src/deploymentMenu.ts +++ b/src/deploymentMenu.ts @@ -1,21 +1,25 @@ import * as jsyaml from 'js-yaml'; -import { ContentsManager } from '@jupyterlab/services'; +import { ServerConnection } from '@jupyterlab/services'; import { Widget } from '@lumino/widgets'; import { Dialog, Notification } from '@jupyterlab/apputils'; import { + appendInfrastructureToList, executeKernelCommand, - getDeployableTemplatesPath, - getInfrastructuresListPath, getIMClientPath, getDeployedTemplatePath, getAuthFilePath, + getAccessTokenFromShareManager, + persistAuthFile, + writeTextFile, + getStateFilePath, createButton } from './utils'; interface IDeployInfo { accessToken: any; + accessTokenSource: 'auto' | 'manual'; recipe: string; id: string; deploymentType: string; @@ -66,6 +70,7 @@ type UserInput = { interface IInfrastructureData { accessToken: string; + accessTokenSource: 'auto' | 'manual'; name: string; infrastructureID: string; id: string; @@ -82,6 +87,7 @@ interface IInfrastructureData { const deployInfo: IDeployInfo = { accessToken: '', + accessTokenSource: 'manual', recipe: '', id: '', deploymentType: '', @@ -153,13 +159,12 @@ const resetDeployInfo = () => { deployInfo.domain = ''; deployInfo.vo = ''; deployInfo.accessToken = ''; + deployInfo.accessTokenSource = 'manual'; }; const footerButtonContainer = document.createElement('div'); footerButtonContainer.className = 'footer-button-container'; -const contentsManager = new ContentsManager(); - let imageOptions: { uri: string; name: string }[] = []; let deploying = false; // Flag to prevent multiple deployments at the same time @@ -234,7 +239,7 @@ const addFormInput = ( function getInputValue(inputId: string): string { const input = document.getElementById(inputId) as HTMLInputElement; - return input.value; + return input?.value || ''; } function detectRecipeFormat(content: string): 'radl' | 'yaml' | 'json' { @@ -248,6 +253,13 @@ function detectRecipeFormat(content: string): 'radl' | 'yaml' | 'json' { return 'radl'; } +function appendVmImagesTitle(container: HTMLElement): void { + const title = document.createElement('p'); + title.textContent = 'VM Images'; + title.classList.add('form-instructions'); + container.appendChild(title); +} + async function createImagesDropdown( output: string | undefined, dropdownContainer: HTMLElement @@ -287,9 +299,10 @@ async function createImagesDropdown( // Clear the dropdown container before appending new content dropdownContainer.innerHTML = ''; + appendVmImagesTitle(dropdownContainer); const label = document.createElement('label'); - label.textContent = 'Images:'; + label.textContent = 'Image:'; label.classList.add('images-label'); dropdownContainer.appendChild(label); @@ -413,19 +426,49 @@ function getMainRecipeFileName(recipeName: string): string { return mainRecipeMap[normalized] ?? normalized.replace(/\s+/g, '_') + '.yaml'; } +async function loadRecipeYaml(recipeName: string): Promise { + const recipeFileName = getMainRecipeFileName(recipeName); + const settings = ServerConnection.makeSettings(); + const baseUrl = settings.baseUrl.endsWith('/') + ? settings.baseUrl + : `${settings.baseUrl}/`; + const recipeUrl = `${baseUrl}lab/extensions/apricot/resources/deployable_templates/${encodeURIComponent( + recipeFileName + )}`; + + try { + const response = await ServerConnection.makeRequest( + recipeUrl, + { method: 'GET' }, + settings + ); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + return await response.text(); + } catch (extensionError) { + console.error( + `Failed to load recipe template '${recipeFileName}' from extension URL '${recipeUrl}'.`, + extensionError + ); + throw extensionError; + } +} + +async function loadRecipeTemplate(recipeName: string): Promise { + const yamlContent = await loadRecipeYaml(recipeName); + return jsyaml.load(yamlContent); +} + async function createChildsForm( childName: string, index: number, deployDialog: HTMLElement, buttonsContainer: HTMLElement ) { - const templatesPath = await getDeployableTemplatesPath(); - - const recipeFileName = getMainRecipeFileName(childName); - const file = await contentsManager.get(`${templatesPath}/${recipeFileName}`); - - const yamlContent = file.content as string; - const yamlData: any = jsyaml.load(yamlContent); + const yamlData: any = await loadRecipeTemplate(childName); const metadata = yamlData.metadata; const templateName = metadata.template_name; const inputs = yamlData.topology_template.inputs; @@ -534,6 +577,8 @@ async function createImageDropdown( dropdownContainer: HTMLElement, nextBtn: HTMLButtonElement ): Promise { + appendVmImagesTitle(dropdownContainer); + const loader = document.createElement('div'); loader.className = 'mini-loader'; dropdownContainer.appendChild(loader); @@ -556,16 +601,10 @@ async function createImageDropdown( async function loadRecipeInputs(recipe: string): Promise { try { - const templatesPath = await getDeployableTemplatesPath(); - const recipeFileName = getMainRecipeFileName(recipe); - const file = await contentsManager.get( - `${templatesPath}/${recipeFileName}` - ); - const yamlContent = file.content as string; - const yamlData: any = jsyaml.load(yamlContent); + const yamlData: any = await loadRecipeTemplate(recipe); return yamlData?.topology_template?.inputs || null; } catch (error) { - console.error('Failed to load recipe inputs:', error); + console.error(`Failed to load recipe inputs for ${recipe}:`, error); return null; } } @@ -576,11 +615,7 @@ async function collectUserInputsFromForm( nodeTemplates: any, outputs: any ): Promise { - const templatesPath = await getDeployableTemplatesPath(); - const recipeFileName = getMainRecipeFileName(childName); - const file = await contentsManager.get(`${templatesPath}/${recipeFileName}`); - const yamlContent = file.content as string; - const yamlData: any = jsyaml.load(yamlContent); + const yamlData: any = await loadRecipeTemplate(childName); const recipeInputs = yamlData.topology_template.inputs; if (!recipeInputs) { @@ -634,38 +669,35 @@ async function collectUserInputsFromForm( } //*********************// -//* Bash commands *// +//* Kernel commands *// //*********************// async function selectImage(obj: IDeployInfo): Promise { const imClientPath = await getIMClientPath(); const authFilePath = await getAuthFilePath(); - // Command to create the IM-cli credentials - let authContent = `id = im; type = InfrastructureManager; token = ${obj.accessToken};\n`; - authContent += `id = ${obj.id}; type = ${obj.deploymentType}; host = ${obj.host}; `; - - if (obj.deploymentType === 'OpenNebula') { - authContent += ` username = ${obj.username}; password = ${obj.password};`; - } else if (obj.deploymentType === 'OpenStack') { - authContent += `username = ${obj.username}; password = ${obj.password}; tenant = ${obj.tenant}; auth_version = ${obj.authVersion}; domain = ${obj.domain}`; - } else if (obj.deploymentType === 'EGI') { - authContent += ` vo = ${obj.vo}; token = ${obj.accessToken}`; - } - - const cmd = `%%bash - PWD=$(pwd) - # Overwrite the auth file with new content - echo -e "${authContent}" > $PWD/${authFilePath} - # Create final command where the output is stored in "imageOut" - imageOut=$(python3 ${imClientPath} -a $PWD/${authFilePath} -r ${imEndpoint} cloudimages ${obj.id}) - # Print IM output on stderr or stdout - if [ $? -ne 0 ]; then - >&2 echo -e $imageOut - exit 1 - else - echo -e $imageOut - fi + await persistAuthFile(obj); + + const cmd = ` +from pathlib import Path +import subprocess + +auth_file = Path(${JSON.stringify(authFilePath)}) +cmd = [ + "python3", + ${JSON.stringify(imClientPath)}, + "-q", + "-a", + str(auth_file), + "-r", + ${JSON.stringify(imEndpoint)}, + "cloudimages", + ${JSON.stringify(obj.id)}, +] +result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) +print(result.stdout) +if result.returncode != 0: + raise RuntimeError(result.stdout) `; // console.log('Get cloud images:', cmd); @@ -684,22 +716,31 @@ async function deployIMCommand( const imClientPath = await getIMClientPath(); const authFilePath = await getAuthFilePath(); - const cmd = `%%bash -PWD=$(pwd) - -# Save mergedTemplate in a file -cat << 'EOF' > ${deployedTemplatePath} -${mergedTemplate} -EOF -# Run IM CLI to deploy using the shared auth file -imageOut=$(python3 ${imClientPath} -a $PWD/${authFilePath} create ${deployedTemplatePath} -r ${imEndpoint}) -# Print IM output on stderr or stdout -if [ $? -ne 0 ]; then - >&2 echo -e $imageOut - exit 1 -else - echo -e $imageOut -fi + await writeTextFile(deployedTemplatePath, mergedTemplate); + const deployedTemplateKernelPath = await getStateFilePath( + `deployed-template.${format}` + ); + + const cmd = ` +from pathlib import Path +import subprocess + +auth_file = Path(${JSON.stringify(authFilePath)}) +template_file = Path(${JSON.stringify(deployedTemplateKernelPath)}) +cmd = [ + "python3", + ${JSON.stringify(imClientPath)}, + "-a", + str(auth_file), + "create", + str(template_file), + "-r", + ${JSON.stringify(imEndpoint)}, +] +result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) +print(result.stdout) +if result.returncode != 0: + raise RuntimeError(result.stdout) `; console.log('TOSCA recipe deployed:', cmd); @@ -708,19 +749,9 @@ fi async function saveToInfrastructureList( obj: IInfrastructureData -): Promise { - const infrastructuresListPath = await getInfrastructuresListPath(); - - // Bash command to update the infrastructuresList JSON - const cmd = `%%bash - PWD=$(pwd) - existingJson=$(cat ${infrastructuresListPath}) - newJson=$(echo "$existingJson" | jq -c '.infrastructures += [${JSON.stringify(obj)}]') - echo "$newJson" > ${infrastructuresListPath} - `; - - console.log('Credentials saved to infrastructuresList.json:', cmd); - return cmd; +): Promise { + await appendInfrastructureToList(obj); + console.log('Data saved to infrastructuresList.json:', obj); } //****************// @@ -771,8 +802,6 @@ const createCheckboxesForChilds = async ( dialogBody: HTMLElement, childs: string[] ): Promise => { - const templatesPath = await getDeployableTemplatesPath(); - // Create paragraph element for checkboxes const paragraph = document.createElement('p'); paragraph.textContent = 'Select optional recipe features:'; @@ -784,15 +813,8 @@ const createCheckboxesForChilds = async ( // Load YAML files and create checkboxes const promises = childs.map(async child => { - // Load YAML file asynchronously - const recipeFileName = getMainRecipeFileName(child); - const file = await contentsManager.get( - `${templatesPath}/${recipeFileName}` - ); - const yamlContent = file.content as string; - // Parse YAML content - const parsedYaml: any = jsyaml.load(yamlContent); + const parsedYaml: any = await loadRecipeTemplate(child); const templateName = parsedYaml.metadata.template_name; // Create list item for checkbox @@ -931,6 +953,7 @@ const deployProviderCredentials = async ( dialogBody.appendChild(form); let text = ''; + let generatedAccessToken = ''; switch (deployInfo.deploymentType) { case 'EC2': { @@ -977,13 +1000,48 @@ const deployProviderCredentials = async ( addFormInput(form, 'Access token:', 'access_token', ''); break; - case 'EGI': - text = '

Introduce EGI credentials.

'; + case 'EGI': { + text = '

Introduce EGI configuration.

'; addFormInput(form, 'VO:', 'vo', deployInfo.vo); addFormInput(form, 'Site name:', 'site', deployInfo.host); - addFormInput(form, 'Access token:', 'access_token', ''); + const accessTokenInput = addFormInput( + form, + 'Access token:', + 'access_token', + '', + 'text', + undefined, + undefined, + 'Getting access token...' + ); + accessTokenInput.disabled = true; + + const tokenStatus = document.createElement('div'); + tokenStatus.className = 'token-status'; + const tokenLoader = document.createElement('div'); + tokenLoader.className = 'mini-loader'; + const tokenStatusText = document.createElement('span'); + tokenStatusText.textContent = 'Getting access token...'; + tokenStatus.appendChild(tokenLoader); + tokenStatus.appendChild(tokenStatusText); + form.appendChild(tokenStatus); + + try { + generatedAccessToken = await getAccessTokenFromShareManager(); + accessTokenInput.value = generatedAccessToken; + accessTokenInput.placeholder = ''; + tokenStatus.remove(); + } catch (error) { + console.warn('Could not get EGI access token automatically:', error); + accessTokenInput.placeholder = 'Paste your EGI access token'; + tokenStatusText.textContent = 'Could not get token automatically.'; + tokenLoader.remove(); + } finally { + accessTokenInput.disabled = false; + } break; + } } form.insertAdjacentHTML('afterbegin', text); @@ -1032,10 +1090,23 @@ const deployProviderCredentials = async ( case 'EGI': deployInfo.host = getInputValue('site'); deployInfo.vo = getInputValue('vo'); - deployInfo.accessToken = getInputValue('access_token'); + deployInfo.accessToken = getInputValue('access_token').trim(); + deployInfo.accessTokenSource = + generatedAccessToken && + deployInfo.accessToken === generatedAccessToken + ? 'auto' + : 'manual'; + + if (!deployInfo.accessToken) { + Notification.error('Please provide a valid EGI access token.', { + autoClose: 5000 + }); + return; + } break; } + await persistAuthFile(deployInfo); deployInfraConfiguration(dialogBody); }); @@ -1067,66 +1138,7 @@ async function deployInfraConfiguration( 'text' ); - const inputs = await loadRecipeInputs(deployInfo.recipe); - - if (inputs) { - // Create form inputs for frontend and worker nodes - Object.entries(inputs) - .filter(([key, _]) => { - if (excludedKeys.has(key)) { - return false; - } - if (deployInfo.recipe === 'simple node disk') { - return true; - } - return key.startsWith('fe_') || key.startsWith('wn_'); - }) - .forEach(([key, inputDef]) => { - const description = (inputDef as any).description || key; - const constraints = (inputDef as any).constraints; - const defaultValue = (inputDef as any).default; - - let inputField: HTMLInputElement | HTMLSelectElement; - - if ( - constraints && - constraints.length > 0 && - constraints[0].valid_values - ) { - inputField = document.createElement('select'); - inputField.name = key; - - constraints[0].valid_values.forEach((value: string) => { - const option = document.createElement('option'); - option.value = value; - option.textContent = value; - if (value === defaultValue) { - option.selected = true; - } - inputField.appendChild(option); - }); - } else { - inputField = document.createElement('input'); - inputField.type = 'text'; - inputField.name = key; - inputField.placeholder = description; - - if (defaultValue !== undefined && defaultValue !== null) { - inputField.value = defaultValue; - } - } - - const label = document.createElement('label'); - label.textContent = description; - label.htmlFor = inputField.name; - form.appendChild(label); - form.appendChild(inputField); - }); - } else { - const noInputsMessage = document.createElement('p'); - noInputsMessage.textContent = 'No frontend or worker node inputs found.'; - form.appendChild(noInputsMessage); - } + let inputs: any | null = null; const buttonContainer = document.createElement('div'); buttonContainer.className = 'footer-button-container'; @@ -1180,7 +1192,9 @@ async function deployInfraConfiguration( value = parseFloat(value); } - originalInputDef.default = value; + if (originalInputDef) { + originalInputDef.default = value; + } deployInfo.inputs[inputName] = value; // console.log(`Updated: '${inputName}' = ${value}`); @@ -1205,11 +1219,76 @@ async function deployInfraConfiguration( } } ); + nextBtn.disabled = true; + buttonContainer.appendChild(nextBtn); + + inputs = await loadRecipeInputs(deployInfo.recipe); + + if (inputs) { + // Create form inputs for frontend and worker nodes + Object.entries(inputs) + .filter(([key, _]) => { + if (excludedKeys.has(key)) { + return false; + } + if (deployInfo.recipe === 'simple node disk') { + return true; + } + return key.startsWith('fe_') || key.startsWith('wn_'); + }) + .forEach(([key, inputDef]) => { + const description = (inputDef as any).description || key; + const constraints = (inputDef as any).constraints; + const defaultValue = (inputDef as any).default; + + let inputField: HTMLInputElement | HTMLSelectElement; + + if ( + constraints && + constraints.length > 0 && + constraints[0].valid_values + ) { + inputField = document.createElement('select'); + inputField.name = key; + + constraints[0].valid_values.forEach((value: string) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + if (value === defaultValue) { + option.selected = true; + } + inputField.appendChild(option); + }); + } else { + inputField = document.createElement('input'); + inputField.type = 'text'; + inputField.name = key; + inputField.placeholder = description; + + if (defaultValue !== undefined && defaultValue !== null) { + inputField.value = defaultValue; + } + } + + const label = document.createElement('label'); + label.textContent = description; + label.htmlFor = inputField.name; + form.appendChild(label); + form.appendChild(inputField); + }); + } else { + const noInputsMessage = document.createElement('p'); + noInputsMessage.textContent = + 'No inputs found. Check that the recipe template is available.'; + form.appendChild(noInputsMessage); + } if (deployInfo.deploymentType !== 'EC2') { nextBtn.disabled = true; + } else { + nextBtn.disabled = false; } - buttonContainer.appendChild(nextBtn); if (deployInfo.deploymentType !== 'EC2') { const dropdownContainer = document.createElement('div'); @@ -1313,14 +1392,7 @@ async function deployFinalRecipe( } } } - const recipeFileName = getMainRecipeFileName(deployInfo.recipe); - - const templatesPath = await getDeployableTemplatesPath(); - const file = await contentsManager.get( - `${templatesPath}/${recipeFileName}` - ); - const yamlContent = file.content; - const parsedTemplate = jsyaml.load(yamlContent) as any; + const parsedTemplate = (await loadRecipeTemplate(deployInfo.recipe)) as any; // Add infrastructure name and a hash to the metadata parsedTemplate.metadata = parsedTemplate.metadata || {}; @@ -1409,6 +1481,7 @@ const handleFinalDeployOutput = async ( // Create a JSON object for infrastructure data const infrastructureData: IInfrastructureData = { accessToken: deployInfo.accessToken, + accessTokenSource: deployInfo.accessTokenSource, name: deployInfo.infName, infrastructureID, id: deployInfo.id, @@ -1423,14 +1496,10 @@ const handleFinalDeployOutput = async ( custom: deployInfo.custom }; - const cmdSave = await saveToInfrastructureList(infrastructureData); - - // Execute kernel command to save the data try { - const outputText = await executeKernelCommand(cmdSave); - console.log('Data saved to infrastructuresList.json:', outputText); + await saveToInfrastructureList(infrastructureData); } catch (error) { - console.error('Error executing kernel command:', error); + console.error('Error saving infrastructure data:', error); deploying = false; } resetDeployInfo(); diff --git a/src/listDeployments.ts b/src/listDeployments.ts index a6d825e..ea28e67 100644 --- a/src/listDeployments.ts +++ b/src/listDeployments.ts @@ -1,18 +1,22 @@ import { Dialog, Notification } from '@jupyterlab/apputils'; import { Widget } from '@lumino/widgets'; import { - getInfrastructuresListPath, getIMClientPath, createButton, getAuthFilePath, executeKernelCommand, - getOrStartKernel + getAccessTokenFromShareManager, + persistAuthFile, + writeAuthFile, + readInfrastructuresList, + removeInfrastructureFromList } from './utils'; interface IInfrastructure { IMuser: string; IMpass: string; accessToken: string; + accessTokenSource?: 'auto' | 'manual'; name: string; infrastructureID: string; id: string; @@ -99,28 +103,16 @@ async function deleteButton( const loader = document.createElement('div'); loader.className = 'mini-loader'; deleteButton.textContent = ''; + deleteButton.disabled = true; + deleteButton.appendChild(loader); try { + await refreshAndPersistListAuth([infrastructure]); const cmdDeploy = await destroyInfrastructure(infrastructureId); - deleteButton.appendChild(loader); - const outputText = await executeKernelCommand(cmdDeploy); - if (outputText && outputText.includes('successfully destroyed')) { - row.remove(); - - Notification.success( - `Infrastructure ${infrastructureId} successfully destroyed.`, - { - autoClose: 5000 - } - ); - console.log(outputText); - - const cmdDeleteInfra = await removeInfraFromList(infrastructureId); - await executeKernelCommand(cmdDeleteInfra); - } else { + if (outputText?.toLowerCase().includes('error')) { Notification.error( 'Error destroying infrastructure. Check the console for more details.', { @@ -128,7 +120,20 @@ async function deleteButton( } ); console.error('Error destroying infrastructure:', outputText); + return; } + + row.remove(); + + Notification.success( + `Infrastructure ${infrastructureId} successfully destroyed.`, + { + autoClose: 5000 + } + ); + console.log(outputText); + + await removeInfrastructureFromList(infrastructureId); } catch (error) { Notification.error( 'Error destroying infrastructure. Check the console for more details.', @@ -140,8 +145,11 @@ async function deleteButton( console.error('Error destroying infrastructure:', error); } finally { // Ensure that the loader is always removed after the try/catch block - deleteButton.removeChild(loader); + if (deleteButton.contains(loader)) { + deleteButton.removeChild(loader); + } deleteButton.textContent = 'Delete'; + deleteButton.disabled = false; } }); @@ -149,30 +157,10 @@ async function deleteButton( } async function populateTable(table: HTMLTableElement): Promise { - let jsonData: string | null = null; - const infrastructuresListPath = await getInfrastructuresListPath(); - console.log('infrastructuresListPath:', infrastructuresListPath); - - const kernel = await getOrStartKernel(); - + let infrastructures: IInfrastructure[] = []; try { - // Read infrastructuresList.json - const cmdReadJson = `%%bash - cat "${infrastructuresListPath}"`; - const futureReadJson = kernel.requestExecute({ code: cmdReadJson }); - - futureReadJson.onIOPub = (msg: any) => { - const content = msg.content as any; - if (content && content.text) { - jsonData = (jsonData || '') + content.text; - } - }; - - await futureReadJson.done; - - if (!jsonData) { - throw new Error('infrastructuresList.json does not exist in the path.'); - } + const data = await readInfrastructuresList(); + infrastructures = data.infrastructures; } catch (error) { console.error('Error reading or parsing infrastructuresList.json:', error); Notification.error( @@ -181,29 +169,12 @@ async function populateTable(table: HTMLTableElement): Promise { autoClose: 5000 } ); - } - - // Parse the JSON data - let infrastructures: IInfrastructure[] = []; - try { - if (jsonData) { - infrastructures = JSON.parse(jsonData).infrastructures; - } - } catch (error) { - console.error( - 'Error parsing JSON data from infrastructuresList.json:', - error - ); - Notification.error( - 'Error parsing JSON data from infrastructuresList.json. Check the console for more details.', - { - autoClose: 5000 - } - ); throw new Error('Error parsing JSON data'); } - // Populate the table rows and fetch IP and state for each infrastructure + await refreshAndPersistListAuth(infrastructures); + + // Populate the table rows and fetch IP and state for each infrastructure. await Promise.all( infrastructures.map(async infrastructure => { const row = table.insertRow(); @@ -230,7 +201,7 @@ async function populateTable(table: HTMLTableElement): Promise { error ); stateCell.textContent = 'Error'; - ipCell.textContent = 'Error'; + ipCell.textContent = 'N/A'; } const deleteBtn = await deleteButton(infrastructure, row); @@ -239,6 +210,50 @@ async function populateTable(table: HTMLTableElement): Promise { ); } +async function refreshAndPersistListAuth( + infrastructures: IInfrastructure[] +): Promise { + let accessToken = ''; + + try { + accessToken = await getAccessTokenFromShareManager(); + } catch (error) { + console.warn( + 'Could not refresh EGI access token before listing deployments.', + error + ); + } + + if (infrastructures.length === 0) { + await writeAuthFile( + `id = im; type = InfrastructureManager; token = ${accessToken || ''}\n` + ); + return; + } + + const egiInfrastructure = infrastructures.find( + infrastructure => infrastructure.type === 'EGI' + ); + + if (accessToken) { + infrastructures + .filter( + infrastructure => + infrastructure.type === 'EGI' && + infrastructure.accessTokenSource !== 'manual' + ) + .forEach(infrastructure => { + infrastructure.accessToken = accessToken; + infrastructure.accessTokenSource = 'auto'; + }); + } + + await persistAuthFile({ + ...(egiInfrastructure || infrastructures[0]), + imAccessToken: accessToken || egiInfrastructure?.accessToken + }); +} + async function getInfrastructureInfo( infrastructure: IInfrastructure, dataType: 'state' | 'ip' @@ -248,26 +263,59 @@ async function getInfrastructureInfo( const imClientPath = await getIMClientPath(); const authFilePath = await getAuthFilePath(); - const cmd = `%%bash - PWD=$(pwd) - if [ "${dataType}" = "state" ]; then - stateOut=$(python3 ${imClientPath} getstate ${infrastructureID} -r ${imEndpoint} -a $PWD/${authFilePath}) - else - stateOut=$(python3 ${imClientPath} getvminfo ${infrastructureID} 0 net_interface.1.ip -r ${imEndpoint} -a $PWD/${authFilePath}) - fi - # Print state output on stderr or stdout - if [ $? -ne 0 ]; then - >&2 echo -e $stateOut - exit 1 - else - echo -e $stateOut - fi + const imArgs = + dataType === 'state' + ? ['getstate', infrastructureID] + : ['getvminfo', infrastructureID, '0', 'net_interface.1.ip']; + + const cmd = ` +from pathlib import Path +import subprocess + +auth_file = Path(${JSON.stringify(authFilePath)}) +cmd = [ + "python3", + ${JSON.stringify(imClientPath)}, + "-q", + *${JSON.stringify(imArgs)}, + "-r", + ${JSON.stringify(imEndpoint)}, + "-a", + str(auth_file), +] +result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) +print(result.stdout) +if result.returncode != 0: + raise RuntimeError(result.stdout) `; console.log(`Get ${dataType} command: `, cmd); return cmd; } +function extractIpAddress(output: string): string { + const ipv4Matches = output.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) || []; + const validIps = ipv4Matches.filter(ip => + ip.split('.').every(octet => Number(octet) <= 255) + ); + + return validIps[validIps.length - 1] || 'N/A'; +} + +function extractInfrastructureState(output: string): string { + try { + const parsedOutput = JSON.parse(output); + if (typeof parsedOutput?.state === 'string') { + return parsedOutput.state; + } + } catch { + // Fall back to parsing older, non-JSON IM client output. + } + + const stateMatch = output.match(/\bstate\b\s*[:=]\s*"?([A-Za-z_-]+)"?/); + return stateMatch?.[1] || 'Error'; +} + async function fetchInfrastructureData( infrastructure: IInfrastructure, dataType: 'state' | 'ip' @@ -277,7 +325,7 @@ async function fetchInfrastructureData( const outputData = await executeKernelCommand(cmd); if (!outputData || outputData.trim() === '') { - return 'No Output'; + return dataType === 'ip' ? 'N/A' : 'No Output'; } console.log(`Received output for ${dataType}:`, outputData); @@ -285,25 +333,19 @@ async function fetchInfrastructureData( let result: string; if (outputData.toLowerCase().includes('error')) { - result = outputData; + result = dataType === 'ip' ? 'N/A' : outputData; } else { if (dataType === 'state') { - const stateWords = outputData.trim().split(' '); - const stateIndex = stateWords.indexOf('state:'); - result = - stateIndex !== -1 && stateIndex < stateWords.length - 1 - ? stateWords[stateIndex + 1].trim() - : 'Error'; + result = extractInfrastructureState(outputData); } else { - const ipWords = outputData.trim().split(' '); - result = ipWords[ipWords.length - 1] || 'Error'; + result = extractIpAddress(outputData); } } return result; } catch (error) { console.error(`Error fetching ${dataType}:`, error); - return 'Error'; + return dataType === 'ip' ? 'N/A' : 'Error'; } } @@ -313,37 +355,31 @@ async function destroyInfrastructure( const imClientPath = await getIMClientPath(); const authFilePath = await getAuthFilePath(); - const cmd = `%%bash - PWD=$(pwd) - # Create final command where the output is stored in "destroyOut" - destroyOut=$(python3 ${imClientPath} destroy ${infrastructureID} -a $PWD/${authFilePath} -r ${imEndpoint}) - # Print IM output on stderr or stdout - if [ $? -ne 0 ]; then - >&2 echo -e $destroyOut - exit 1 - else - echo -e $destroyOut - fi + const cmd = ` +from pathlib import Path +import subprocess + +auth_file = Path(${JSON.stringify(authFilePath)}) +cmd = [ + "python3", + ${JSON.stringify(imClientPath)}, + "-q", + "destroy", + "-y", + ${JSON.stringify(infrastructureID)}, + "-a", + str(auth_file), + "-r", + ${JSON.stringify(imEndpoint)}, +] +result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) +print(result.stdout) +if result.returncode != 0: + raise RuntimeError(result.stdout) `; console.log(cmd); return cmd; } -async function removeInfraFromList(infrastructureID: string): Promise { - const infrastructuresListPath = await getInfrastructuresListPath(); - - // Create a Bash command to remove the infrastructure from the JSON file - const cmdDeleteInfra = ` - %%bash - PWD=$(pwd) - existingJson=$(cat ${infrastructuresListPath}) - newJson=$(echo "$existingJson" | jq -c 'del(.infrastructures[] | select(.infrastructureID == "${infrastructureID}"))') - echo "$newJson" > ${infrastructuresListPath} - `; - - console.log(`Bash Command: ${cmdDeleteInfra}`); - return cmdDeleteInfra; -} - export { openListDeploymentsDialog }; diff --git a/src/utils.ts b/src/utils.ts index 2a0dead..5dd9c0a 100755 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,12 @@ import { KernelManager } from '@jupyterlab/services'; import { Notification } from '@jupyterlab/apputils'; +const infrastructuresStateDir = 'apricotlab_state'; +const infrastructuresStatePath = `${infrastructuresStateDir}/infrastructuresList.json`; +const authFileStatePath = `${infrastructuresStateDir}/authfile`; +const defaultAuthFileContent = + 'id = im; type = InfrastructureManager; token = \n'; + let kernelManager: KernelManager | null = null; let kernel: any | null = null; @@ -18,31 +24,40 @@ export async function executeKernelCommand(command: string): Promise { const future = kernelInstance.requestExecute({ code: command }); let outputText = ''; - let timeout: ReturnType; return new Promise((resolve, reject) => { // Listen for output future.onIOPub = (msg: { content: any }) => { const content = msg.content; const currentOutput = - content.text || (content.data && content.data['text/plain']); + content.text || + content.evalue || + (content.data && content.data['text/plain']); // If there is output, accumulate it if (currentOutput) { outputText += currentOutput; - clearTimeout(timeout); // Reset timeout if data comes in early - timeout = setTimeout(() => { - resolve(outputText.trim()); // Resolve after a delay to ensure all data is received - }, 500); // 500ms delay before resolving } }; - // Handle errors in command execution - future.onFinished = (msg: { content: { status: string } }) => { - if (msg.content.status !== 'ok') { - reject(new Error(`Kernel execution failed: ${msg.content.status}`)); - } - }; + future.done + .then((msg: { content: { status: string } }) => { + if (msg.content.status !== 'ok') { + reject( + new Error( + outputText || `Kernel execution failed: ${msg.content.status}` + ) + ); + return; + } + + setTimeout(() => { + resolve(outputText.trim()); + }, 100); + }) + .catch((error: Error) => { + reject(error); + }); }); } @@ -61,10 +76,277 @@ async function getPath( } } +async function ensureInfrastructuresStateDir(): Promise { + const cmd = ` +from pathlib import Path +import shutil + +state_name = ${JSON.stringify(infrastructuresStateDir)} +state_dir = Path.home() / state_name +state_dir.mkdir(exist_ok=True) + +for legacy_dir in (Path.cwd() / state_name, Path.cwd().parent / state_name): + if legacy_dir.exists() and legacy_dir.resolve() != state_dir.resolve(): + for legacy_file in legacy_dir.iterdir(): + target = state_dir / legacy_file.name + if legacy_file.is_file() and not target.exists(): + shutil.copy2(legacy_file, target) + `; + await executeKernelCommand(cmd); +} + +async function writeInfrastructuresList(data: any): Promise { + await ensureInfrastructuresStateDir(); + const cmd = ` +from pathlib import Path + +path = Path.home() / ${JSON.stringify(infrastructuresStatePath)} +path.write_text(${JSON.stringify(JSON.stringify(data, null, 2))}) + `; + await executeKernelCommand(cmd); +} + +export async function readInfrastructuresList(): Promise { + try { + await ensureInfrastructuresStateDir(); + const cmd = ` +from pathlib import Path +import json + +path = Path.home() / ${JSON.stringify(infrastructuresStatePath)} +if not path.exists(): + path.write_text(json.dumps({"refresh_token": "", "infrastructures": []}, indent=2)) +print(path.read_text()) + `; + const content = await executeKernelCommand(cmd); + const data = JSON.parse(content); + + return { + refresh_token: data?.refresh_token || '', + infrastructures: data?.infrastructures || [] + }; + } catch (error) { + console.warn( + `${infrastructuresStatePath} not found or unreadable. Creating an empty list.`, + error + ); + const initialData = { + refresh_token: '', + infrastructures: [] + }; + await writeInfrastructuresList(initialData); + return initialData; + } +} + +export async function appendInfrastructureToList( + infrastructure: any +): Promise { + const data = await readInfrastructuresList(); + data.infrastructures = [...(data.infrastructures || []), infrastructure]; + await writeInfrastructuresList(data); +} + +export async function removeInfrastructureFromList( + infrastructureID: string +): Promise { + const data = await readInfrastructuresList(); + data.infrastructures = (data.infrastructures || []).filter( + (infrastructure: any) => + infrastructure.infrastructureID !== infrastructureID + ); + await writeInfrastructuresList(data); +} + +export async function writeAuthFile(content: string): Promise { + await ensureInfrastructuresStateDir(); + const cmd = ` +from pathlib import Path + +path = Path.home() / ${JSON.stringify(authFileStatePath)} +path.write_text(${JSON.stringify(content)}) + `; + await executeKernelCommand(cmd); +} + +export async function writeTextFile( + path: string, + content: string +): Promise { + const cmd = ` +from pathlib import Path + +path = Path.home() / Path(${JSON.stringify(path)}) +path.parent.mkdir(exist_ok=True) +path.write_text(${JSON.stringify(content)}) + `; + await executeKernelCommand(cmd); +} + +export async function readAuthFile(): Promise { + try { + await ensureInfrastructuresStateDir(); + const cmd = ` +from pathlib import Path + +path = Path.home() / ${JSON.stringify(authFileStatePath)} +print(path.read_text() if path.exists() else "") + `; + const content = await executeKernelCommand(cmd); + + if (content) { + return content; + } + } catch { + // Missing authfile is expected on a fresh workspace. + } + + await writeAuthFile(defaultAuthFileContent); + return defaultAuthFileContent; +} + +export function getBrowserToken(): string { + const jupyterConfigElement = document.querySelector('#jupyter-config-data'); + const jupyterConfig = jupyterConfigElement + ? JSON.parse(jupyterConfigElement.innerHTML) + : {}; + + return jupyterConfig.token || ''; +} + +function extractAccessToken(payload: any): string { + if (typeof payload === 'string') { + return payload.trim(); + } + + return ( + payload?.access_token || + payload?.accessToken || + payload?.token || + payload?.data?.access_token || + '' + ); +} + +export async function getAccessTokenFromShareManager(): Promise { + const browserToken = getBrowserToken(); + + if (!browserToken) { + throw new Error('Jupyter browser token not found.'); + } + + const response = await fetch( + 'https://notebooks.egi.eu/services/share-manager/token', + { + headers: { + Authorization: `bearer ${browserToken}` + } + } + ); + + const responseText = await response.text(); + + if (!response.ok) { + throw new Error( + responseText || `${response.status} ${response.statusText}` + ); + } + + let payload: any = responseText.trim(); + try { + payload = JSON.parse(responseText); + } catch { + // The endpoint may return the token as plain text. + } + + const accessToken = extractAccessToken(payload); + + if (!accessToken) { + throw new Error('Share-manager response did not include an access token.'); + } + + return accessToken; +} + +export function buildAuthFileContent(obj: { + accessToken?: string; + imAccessToken?: string; + id: string; + deploymentType?: string; + type?: string; + host: string; + username?: string; + user?: string; + password?: string; + pass?: string; + tenant?: string; + authVersion?: string; + domain?: string; + vo?: string; +}): string { + const deploymentType = obj.deploymentType || obj.type || ''; + const username = obj.username || obj.user || ''; + const password = obj.password || obj.pass || ''; + const imAccessToken = obj.imAccessToken || obj.accessToken || ''; + let authContent = `id = im; type = InfrastructureManager; token = ${imAccessToken};\n`; + authContent += `id = ${obj.id}; type = ${deploymentType}; host = ${obj.host}; `; + + if (deploymentType === 'OpenNebula') { + authContent += ` username = ${username}; password = ${password};`; + } else if (deploymentType === 'OpenStack') { + authContent += `username = ${username}; password = ${password}; tenant = ${obj.tenant || ''}; auth_version = ${obj.authVersion || ''}; domain = ${obj.domain || ''}`; + } else if (deploymentType === 'EGI') { + authContent += ` vo = ${obj.vo || ''}; token = ${obj.accessToken || ''}`; + } + authContent += '\n'; + + return authContent; +} + +export async function persistAuthFile(obj: { + accessToken?: string; + imAccessToken?: string; + id: string; + deploymentType?: string; + type?: string; + host: string; + username?: string; + user?: string; + password?: string; + pass?: string; + tenant?: string; + authVersion?: string; + domain?: string; + vo?: string; +}): Promise { + await writeAuthFile(buildAuthFileContent(obj)); +} + +function getStatePathCommand(statePath: string): string { + return ` +from pathlib import Path + +state = Path(${JSON.stringify(statePath)}) +print(Path.home() / state) + `; +} + export async function getIMClientPath(): Promise { const cmdIMClientPath = ` - %%bash - which im_client.py +from pathlib import Path +import shutil + +candidates = [ + shutil.which("im_client.py"), + shutil.which("im_client"), +] + +for candidate in candidates: + if candidate and Path(candidate).exists(): + print(candidate) + break +else: + raise FileNotFoundError("Could not find im_client.py") `; return getPath( cmdIMClientPath, @@ -75,47 +357,33 @@ export async function getIMClientPath(): Promise { export async function getDeployedTemplatePath( ext: 'yaml' | 'json' | 'radl' ): Promise { - const cmdDeployedTemplatePath = ` - %%bash - realpath --relative-to="$(pwd)" resources/deployed-template.${ext} - `; + await ensureInfrastructuresStateDir(); + return `${infrastructuresStateDir}/deployed-template.${ext}`; +} + +export async function getStateFilePath(fileName: string): Promise { + await ensureInfrastructuresStateDir(); + const cmdStateFilePath = getStatePathCommand( + `${infrastructuresStateDir}/${fileName}` + ); return getPath( - cmdDeployedTemplatePath, - `Failed to find resources/deployed-template.${ext}. Maybe it is not in the resources folder. Check the console for more details.` + cmdStateFilePath, + `Failed to find ${infrastructuresStateDir}/${fileName}. Check the console for more details.` ); } export async function getInfrastructuresListPath(): Promise { - const cmdInfrastructuresListPath = ` - %%bash - realpath --relative-to="$(pwd)" resources/infrastructuresList.json - `; - return getPath( - cmdInfrastructuresListPath, - 'Failed to find resources/infrastructuresList.json. Maybe it is not in the resources folder. Check the console for more details.' - ); + await readInfrastructuresList(); + return getStateFilePath('infrastructuresList.json'); } export async function getDeployableTemplatesPath(): Promise { - const cmdTemplatesPath = ` - %%bash - realpath --relative-to="$(pwd)" resources/deployable_templates - `; - return getPath( - cmdTemplatesPath, - 'Failed to find resources/deployable_templates/ directory. Maybe it is not in the project folder. Check the console for more details.' - ); + return 'resources/deployable_templates'; } export async function getAuthFilePath(): Promise { - const cmdTemplatesPath = ` - %%bash - realpath --relative-to="$(pwd)" resources/authfile - `; - return getPath( - cmdTemplatesPath, - 'Failed to find resources/authfile directory. Maybe it is not in the project folder. Check the console for more details.' - ); + await readAuthFile(); + return getStateFilePath('authfile'); } export const createButton = (