From 70e9df9b33861f565f7cda1a652118701ce770c6 Mon Sep 17 00:00:00 2001 From: Minimega12121 Date: Thu, 19 Sep 2024 17:56:33 +0530 Subject: [PATCH 1/3] Reproducibility Service contribution --- .../reproducibility_service/APP-REQ/logo.png | Bin 0 -> 13468 bytes .../APP-REQ/ro-crate-info.yaml | 33 ++ .../APP-REQ/status_table.png | Bin 0 -> 79292 bytes .../tools/reproducibility_service/README.md | 54 ++ .../compss_reproducibility_service | 219 +++++++ .../data_persistance_false.py | 522 +++++++++++++++++ .../file_operations.py | 157 +++++ .../reproducibility_service/file_verifier.py | 111 ++++ .../reproducibility_service/get_workflow.py | 146 +++++ .../new_dataset_backend.py | 50 ++ .../provenance_backend.py | 102 ++++ .../reproducibility_service/pseudocode.txt | 54 ++ .../reproducibility_service/remote_dataset.py | 55 ++ .../reproducibility_methods/__init__.py | 1 + .../reproducibility_methods/address_mapper.py | 147 +++++ .../generate_command_line.py | 185 ++++++ .../reproducibility_methods/utilsr.py | 58 ++ compss/tools/reproducibility_service/utils.py | 539 ++++++++++++++++++ 18 files changed, 2433 insertions(+) create mode 100644 compss/tools/reproducibility_service/APP-REQ/logo.png create mode 100644 compss/tools/reproducibility_service/APP-REQ/ro-crate-info.yaml create mode 100644 compss/tools/reproducibility_service/APP-REQ/status_table.png create mode 100644 compss/tools/reproducibility_service/README.md create mode 100755 compss/tools/reproducibility_service/compss_reproducibility_service create mode 100644 compss/tools/reproducibility_service/data_persistance_false.py create mode 100644 compss/tools/reproducibility_service/file_operations.py create mode 100644 compss/tools/reproducibility_service/file_verifier.py create mode 100644 compss/tools/reproducibility_service/get_workflow.py create mode 100644 compss/tools/reproducibility_service/new_dataset_backend.py create mode 100644 compss/tools/reproducibility_service/provenance_backend.py create mode 100644 compss/tools/reproducibility_service/pseudocode.txt create mode 100644 compss/tools/reproducibility_service/remote_dataset.py create mode 100644 compss/tools/reproducibility_service/reproducibility_methods/__init__.py create mode 100644 compss/tools/reproducibility_service/reproducibility_methods/address_mapper.py create mode 100644 compss/tools/reproducibility_service/reproducibility_methods/generate_command_line.py create mode 100644 compss/tools/reproducibility_service/reproducibility_methods/utilsr.py create mode 100644 compss/tools/reproducibility_service/utils.py diff --git a/compss/tools/reproducibility_service/APP-REQ/logo.png b/compss/tools/reproducibility_service/APP-REQ/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0ce298d687b97d4aeec3d181b6def3536ee0153b GIT binary patch literal 13468 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 HFIb% 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 literal 0 HcmV?d00001 diff --git a/compss/tools/reproducibility_service/README.md b/compss/tools/reproducibility_service/README.md new file mode 100644 index 0000000000..afa669721d --- /dev/null +++ b/compss/tools/reproducibility_service/README.md @@ -0,0 +1,54 @@ +# COMPSs-Reproducibility-Service + +

+ Logo +

+ +This is an automatic reproducibility service designed to help reproduce COMPSs workflows on your local machine or on a SLURM cluster. Below are the prerequisites and instructions for proper functioning. + +## Pre-requisites + +- COMPSs must be installed on your local machine, or the COMPSs module must be loaded on the cluster. For installation guidance, refer to the [COMPSs Official Installation Guide](https://compss-doc.readthedocs.io/en/stable/Sections/01_Installation.html). +- Ensure that all dependencies for the experiment you wish to reproduce are satisfied on the machine where you want to resubmit the application. + +## How to Use + +- Take the remote URL to the workflow (i.e. from WorkflowHub) or the path to the RO-Crate (a folder or a zip file) and pass it as the first argument to the service: + ```bash + python3 reproducibility_service.py +- The rest of the steps are self-explanatory and occur as interactions with the program, allowing the following features: + +## Features + +1. **Provenance Generation**: The program prompts you for a provenance flag (`-p` flag for `runcompss`). It automatically fetches the experiment details from the metadata and only asks for the `Submitter` details. + +2. **New Dataset Feature**: If you want to reproduce the same experiment with a new dataset, simply provide the path to the new dataset. + > **Note**: The new dataset should follow the exact same directory structure as the old one for the paths to be correctly mapped. + +3. **Flag Addition**: You can review the `runcompss` command line generated by the service and pass additional flags according to the needs of your new run. + +4. **File Verification**: The service verifies file integrity against metadata such as file size or modification date. It generates a status table displaying the results of the verification. +

+ Logo +

+ +5. **Sub-directory Feature**: The service execution occurs in a separate subdirectory named `reproducibility_service_{timestamp}`, ensuring that it does not interfere with the current working directory (cwd). + +6. **Results**: Any results generated by the experiment are stored in `reproducibility_service_{timestamp}/Results`. If provenance is requested, the generated RO-Crate is also stored in this directory. + +7. **Logging**: Logs from the reproducibility service, such as `err.log`, `out.log`, and `rs_log`, are stored in `reproducibility_service_{timestamp}/log`. + +## Known Issues (or Future Plans) + +- Third party software dependencies: neither automatic detection nor loading those dependencies on a SLURM cluster are implemented. Currently, they need to be solved manually by the user. +- No support for workflows with `data_persistence = False` with all datasets as remote files. + +### Experiment Requirements + +1. If a folder path is provided in the `compss_submission_command_line`, the path should end with a `/`. +2. The service does not support experiments with file paths inside the source code, as these paths cannot be easily mapped. +3. The `data_persistence = False` examples are only supposed to work on the original SLURM cluster where paths related to the experiment are accessible (i.e. the new Submitter may need to request access permissions). + +--- + +I hope you find this service helpful! diff --git a/compss/tools/reproducibility_service/compss_reproducibility_service b/compss/tools/reproducibility_service/compss_reproducibility_service new file mode 100755 index 0000000000..ca9cff9648 --- /dev/null +++ b/compss/tools/reproducibility_service/compss_reproducibility_service @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 + +# +# Copyright 2002-2022 Barcelona Supercomputing Center (www.bsc.es) +# +# Licensed 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. +# +# Authors: Archit Dabral and Raül Sirvent + + +""" +Reproducibility Service Main Module + +This module serves as the entry point for the Reproducibility Service, a tool designed to automate +the process of reproducing computational experiments and workflows. It integrates various components +and modules to ensure that experiments can be consistently and accurately reproduced, either using +existing datasets or new ones. + +""" + +import os +import sys +import signal +from rocrate.rocrate import ROCrate + +from reproducibility_methods import generate_command_line +from file_operations import move_results_created,create_new_execution_directory +from file_verifier import files_verifier +from utils import ( + check_compss_version,check_slurm_cluster,executor, + get_compss_crate_version,get_data_persistence_status,get_instument, + get_objects_dict,get_previous_flags,get_yes_or_no,print_colored, + print_welcome_message,TextColor,get_create_action_name +) +from new_dataset_backend import new_dataset_info_collector +from provenance_backend import provenance_info_collector, update_yaml, provenance_checker +from get_workflow import get_workflow, get_more_flags, get_change_values +from remote_dataset import remote_dataset +from data_persistance_false import data_persistence_false_verifier, run_dpf + +SUB_DIRECTORY_PATH:str = None +SERVICE_PATH:str = None +COMPSS_VERSION:str = None +SLURM_CLUSTER:bool = None +DPF: bool = False +CRATE_PATH: str = None +DATA_PERSISTENCE: bool = False + +def interrupt_handler(signum, frame): # signal handler for cleaning up in case of an interrupt + """ + Signal handler for safely exiting in case of interrupt. + """ + print_colored(f"Reproducibility Service has been interrupted with signal {signum}.", TextColor.RED) + print_colored("Exiting the program.", TextColor.RED) + sys.exit(0) + +signal.signal(signal.SIGINT, interrupt_handler) # register the signal handler + +class Unbuffered: + """ + Unbuffered class for logging purposes. + """ + def __init__(self, stream): + self.stream = stream + + def write(self, data): + self.stream.write(data) + self.stream.flush() + te.write(data) + + def flush(self): + self.stream.flush() + te.flush() + +class ReproducibilityService: + """ + Reproducibility Service class for executing the reproducibility service. + + __init__: Initialises the Reproducibility Service with the given flags. + And verifies the files and fills other necessary information. + + run(): Run the reproducibility service by submitting the final command to the executor. + """ + def __init__(self, provenance_flag:bool, new_dataset_flag:bool) -> bool: + global CRATE_PATH + self.crate_directory = CRATE_PATH + self.provenance_flag = provenance_flag + self.new_dataset_flag = new_dataset_flag + self.root_folder = SERVICE_PATH + self.remote_dataset_flag = False + + crate_compss_version:str = get_compss_crate_version(self.crate_directory) + print_colored(f"COMPSs version used in the original run: {crate_compss_version}", TextColor.BLUE) + + # not using currently to run 3.3.1,3.3 examples on a 3.3 or 3.3.1 compss machine + if COMPSS_VERSION != crate_compss_version: + print_colored(f"WARNING: The crate was created with COMPSs version: {get_compss_crate_version(self.crate_directory)}, which differs with the COMPSs version found locally: {COMPSS_VERSION}", TextColor.YELLOW) + + try: + crate = ROCrate(self.crate_directory) + print_colored(f"THE RUN WAS: {get_create_action_name(crate)}", TextColor.YELLOW) + global DATA_PERSISTENCE + if not DATA_PERSISTENCE: + data_persistence_false_verifier(self.crate_directory) + global DPF + DPF = True + return + + if new_dataset_flag: + new_dataset_info_collector(self.crate_directory) + else: # verify the metadata only if the old dataset is used + # print("Reproducing the crate on the old dataset.") + instrument = get_instument(crate) + objects = get_objects_dict(crate) + # download the remote data-set if it exists and return true if it exists + (self.remote_dataset_flag, remote_dataset_dict) = remote_dataset(crate, self.crate_directory) + files_verifier(self.crate_directory, instrument, objects, remote_dataset_dict) + if provenance_flag: #update the sources inside the yaml file + update_yaml(self.crate_directory) + + except Exception as e: + print_colored(e,TextColor.RED) + sys.exit(1) + + self.log_folder = os.path.join(SUB_DIRECTORY_PATH, 'log') + + def run(self): + try: + new_command = generate_command_line(self, SUB_DIRECTORY_PATH) + initial_files = set(os.listdir(os.getcwd())) + if self.provenance_flag: # add the provenance flag to the command + new_command.insert(1, "--provenance") + + previous_flags = get_previous_flags(self.crate_directory)# get the previous flags to show the user as reference + new_command = get_more_flags(new_command, previous_flags) # ask user for more flags he/she wants to add to the final compss command + new_command = get_change_values(new_command) + + result = executor(new_command,SUB_DIRECTORY_PATH) + move_results_created(initial_files, SUB_DIRECTORY_PATH) + + return result + + except Exception as e: + print_colored(e, TextColor.RED) + return False + +if __name__ == "__main__": + try: + if len(sys.argv) < 2: + print_colored("Please provide the link or the path to the RO-Crate.", TextColor.RED) + sys.exit(1) + if len(sys.argv) > 2: + print_colored("Too many arguments provided. Please provide only the link or the path to the RO-Crate.", TextColor.RED) + sys.exit(1) + print_welcome_message() + NEW_DATASET_FLAG = False + PROVENANCE_FLAG = False + COMPSS_VERSION:str = check_compss_version() # To check if compss is installed, if yes extract the version, else exit the program + SLURM_CLUSTER = check_slurm_cluster()[0] # To check if the program is running on the SLURM cluster + print("Slurm cluster:", SLURM_CLUSTER) + SERVICE_PATH= os.path.dirname(os.path.abspath(__file__)) + print("Service path is:", SERVICE_PATH) + SUB_DIRECTORY_PATH = create_new_execution_directory(SERVICE_PATH) + print("Sub-directory path is:", SUB_DIRECTORY_PATH) + + te = open(os.path.join(SUB_DIRECTORY_PATH,'log/rs_log.txt'), 'w', encoding='utf-8') # for logging purposes + + sys.stdout = Unbuffered(sys.stdout) # for logging + + link_or_path = sys.argv[1] # take the link or path given by the user + print(f"Source for crate: {link_or_path}") + CRATE_PATH = get_workflow(SUB_DIRECTORY_PATH, link_or_path) + # print("Crate path is:",CRATE_PATH) + DATA_PERSISTENCE = get_data_persistence_status(CRATE_PATH) + print_colored(f"DATA PERSISTENCE IN THE CRATE WAS: {DATA_PERSISTENCE}", TextColor.YELLOW) + os.chdir(SUB_DIRECTORY_PATH) # Avoid problems when relative paths are used as parameter + + if not SLURM_CLUSTER: + NEW_DATASET_FLAG = get_yes_or_no("Do you want to reproduce the crate on a new dataset?") + + if not SLURM_CLUSTER or DATA_PERSISTENCE: #can generate provenance for dpt + PROVENANCE_FLAG = provenance_info_collector(SUB_DIRECTORY_PATH, SERVICE_PATH) + + rs = ReproducibilityService(PROVENANCE_FLAG, NEW_DATASET_FLAG) + RESULT = False #default value + if DPF: + # print(rs.crate_directory) + RESULT = run_dpf(SUB_DIRECTORY_PATH, rs.crate_directory) + else: + RESULT = rs.run() + if RESULT: + print_colored("Reproducibility Service has been executed successfully", TextColor.GREEN) + else: + print_colored("Reproducibility Service has failed", TextColor.RED) + + if PROVENANCE_FLAG and not SLURM_CLUSTER: + provenance_checker(SUB_DIRECTORY_PATH) + + except FileNotFoundError as e: + print_colored(e, TextColor.RED) + sys.exit(1) + except ValueError as e: + print_colored(e, TextColor.RED) + sys.exit(1) + except Exception as e: + print_colored(e,TextColor.RED) + sys.exit(1) + + sys.exit(0) diff --git a/compss/tools/reproducibility_service/data_persistance_false.py b/compss/tools/reproducibility_service/data_persistance_false.py new file mode 100644 index 0000000000..b0ba057b03 --- /dev/null +++ b/compss/tools/reproducibility_service/data_persistance_false.py @@ -0,0 +1,522 @@ +""" + Data Persistance False (DPF) Module + + This is for the worflow where the data persistance is set to false. It is exclusively for SLURM clusters. + This module verifies the accessibility of the files in the crate and checks if the files are accessible. + It also verifies the files in the crate against their metadata. It also checks if the crate was created + with data persistance set to false and runs the workflow on the cluster in which the dataset paths are available. +""" +import os +import datetime as dt +import re +import shlex + +from urllib.parse import urlparse +from rocrate.rocrate import ROCrate +from utils import print_colored, TextColor, get_objects, get_by_id, get_instument, get_objects_dict +from utils import get_results_dict, check_slurm_cluster, executor, get_previous_flags +from utils import get_file_names, generate_file_status_table +from get_workflow import get_more_flags, get_change_values + +RESULT_PATH:str = None +OUTPUT_NUM:int = 0 + + +def check_file_accessibility(crate: ROCrate) -> tuple[bool,dict]: + """ + Check if the specified file paths are accessible. + + Parameters: + file_paths (list): A list of file paths to check. + + Returns: + dict: A dictionary with file paths as keys and a boolean indicating if the file is accessible as values. + """ + file_paths = get_objects(crate) + accessibility = {} + flag = True + for path in file_paths: + if path.startswith("http"): + # do not consider remote path for cluster due to no connection + continue + parsed_url = urlparse(path) + # Remove the 'file://' prefix + file_path = path.replace(f"file://{parsed_url.netloc}", "") + + if file_path: + accessibility[file_path] = os.access(file_path, os.R_OK) + if accessibility[file_path] == False: + flag = False + # print(file_path+ "\n" + "INACCESSIBLE") + # else: + # print(file_path+ "\n" + "ACCESSIBLE") + + return flag,accessibility + +def files_verifier_dpf(crate_path: str): + """ + Verify files within an RO-Crate against their metadata. + + Args: + crate_path (str): Path to the root directory of the RO-Crate. + instrument (str): Identifier of the instrument file within the RO-Crate. + objects (list[str]): List of identifiers for objects/inputs within the RO-Crate. + + Raises: + FileNotFoundError: If any referenced file in the RO-Crate does not exist in the directory. + ValueError:If the content size of any file does not match the recorded size in the RO-Crate. + + Notes: + This function verifies the existence and size of files referenced in the RO-Crate + against the actual files in the specified directory. Optionally, it can also verify + modification dates, although this feature is currently commented out. + """ + crate = ROCrate(crate_path) + instrument = get_instument(crate) + size_verifier = True + date_verifier = True + temp_size = [] + temp_date = [] + crate = ROCrate(crate_path) + instrument_path = os.path.join(crate_path, instrument) + + file_verifer = [] # tuple of (file_name, file_path, Date_modified ,file_size) + instrument_tuple = (instrument, instrument_path, 1, 1) + if not os.path.getsize(instrument_path) == get_by_id(crate, instrument)["contentSize"]: + size_verifier = False + temp_size.append(instrument_path) + instrument_tuple = (instrument_tuple[0], instrument_tuple[1], instrument_tuple[2], 0) + file_verifer.append(instrument_tuple) + #Verify the objects/inputs + crate = ROCrate(crate_path) + file_paths = get_objects(crate) + for path in file_paths: + if path.startswith("http"): + # do not consider remote path for cluster due to no connection + continue + + parsed_url = urlparse(path) + # Remove the 'file://' prefix + file_path = path.replace(f"file://{parsed_url.netloc}", "") + + file_object = get_by_id(crate, path) + file_tuple = (path, file_path, 1, 2) + if "contentSize" in file_object: + content_size = file_object["contentSize"] # Verify the above content size with the actual file size + # Get the actual file size + actual_size = os.path.getsize(file_path) + + # Verify the content size with the actual file size + if actual_size != content_size: + file_tuple = (file_tuple[0], file_tuple[1], file_tuple[2], 0) + # print(f"Size of {file_path} is incorrect") + size_verifier = False + temp_size.append(file_path) + else: + file_tuple = (file_tuple[0], file_tuple[1], file_tuple[2], 1) + # print(f"Size of {file_path} is correct") + + actual_modified_date = dt.datetime.utcfromtimestamp(os.path.getmtime(file_path)).replace(microsecond=0).isoformat() + if "dateModified" in file_object and actual_modified_date != file_object["dateModified"][:-6]: + # print(f"DateModified of {file_path} is incorrect\n") + date_verifier = False + temp_date.append(file_path) + file_tuple = (file_tuple[0], file_tuple[1], 0, file_tuple[3]) + # else: + # print(f"DateModified of {file_path} is correct") + file_verifer.append(file_tuple) + + print_colored("STATUS TABLE (the crate includes REFERENCES to the files the workflow needs to run, data persistence was FALSE):", TextColor.YELLOW) + + generate_file_status_table(file_verifer, "Mod. Date") + if date_verifier: + print_colored("All files have correct Modification Date", TextColor.GREEN) + else: + print_colored("WARNING: Modification Date mismatch in the application input files. Re-execution may not work or may lead to different results.", TextColor.RED) + + if size_verifier: + print_colored("All files have correct Sizes", TextColor.GREEN) + else: + print_colored( + "WARNING: File Size mismatch in the application input files. Re-execution may not work or may lead to different results.", + TextColor.RED) + +def data_persistence_false_verifier(crate_path:str): + """ + Verify if the crate was created with data persistance set to false. + + Args: + crate_path (str): the path to the root directory of the RO-Crate. + + Raises: + ValueError: If some files are not accessible + """ + # if check_slurm_cluster()[0]: + # print("Slurm cluster") + # else: + # print("Not a Slurm cluster") + # raise ValueError ("The crate was created with data persistence set to false. Please run the crate on the cluster in which the dataset paths are available.") + + crate = ROCrate(crate_path) + + (accessible,access_map) = check_file_accessibility(crate) + + if not accessible: + print_colored("The following paths are not accessible:", TextColor.RED) + for p in access_map: + if not access_map[p]: + print_colored(p, TextColor.RED) + raise ValueError + + else: + print_colored("All files are accessible", TextColor.GREEN) + # print("Checking file sizes...") + try: + files_verifier_dpf(crate_path) + except ValueError as e: + print_colored(str(e), TextColor.RED) + +def addr_extractor(path: str) -> dict: + """ + Extracts the addresses of datasets in the given path. For this particular case, + it is used to extract the mapping of filenames in the crate/dataset and + crate/application_sources directories. + + Args: + path (str): The path to the directory containing the datasets. + + Returns: + dict: A dictionary mapping dataset filenames to a value of 1. + """ + if not os.path.exists(path): + os.makedirs(path) + hash_map = {} + for filename in os.listdir(path): + hash_map[filename] = 1 + + return hash_map +def address_converter_backend(path: str, addr: str, dataset_hashmap: dict) -> str: + """ + Converts the given address to a mapped address inside the RO_Crate + based on the dataset hashmap. + + Args: + path (str): The base path of the dataset. + addr (str): The address to be converted. + dataset_hashmap (dict): A dictionary containing the mapping of dataset addresses. + + Returns: + str: The mapped address corresponding to the given address. + + Raises: + FileNotFoundError: If the mapped address for the given address is not found. + """ + filename = None + mapped_addr = None + + if addr.startswith("./"): + addr = addr[1:] + + if not addr.startswith("/"): + addr = "/" + addr + + # Check if the address is a file or a directory and split it accordingly + if addr.endswith("/"): + addr_list = addr.split("/") + else: + addr_list = addr.split("/") + filename = addr_list.pop() + + for i in range(1, len(addr_list)): + if addr_list[i] in dataset_hashmap: + temp_addr = os.path.join(path, addr_list[i]) + for j in range(i + 1, len(addr_list)): + temp_addr = os.path.join(temp_addr, addr_list[j]) + + if os.path.exists(temp_addr): + mapped_addr = temp_addr + break + + # If the address is a file, append the filename and check if exists + if filename: + if not mapped_addr: + mapped_addr = path + mapped_addr = os.path.join(mapped_addr, filename) + if not os.path.exists(mapped_addr): + return None + + return mapped_addr + +def address_mapper_dpf(addr:str, object_list: list, result_list: list, application_sources_hash_map: dict, path:str) -> str: + """ + Map the given address to a path inside the RO-Crate based on the given object and result lists. + + Args: + addr (str): the address to map + object_list (_type_): list of objects from metadata + result_list (_type_): list of results from metadata + application_sources_hash_map (dict): hashmap of application sources + path (str): the path to the RO-Crate + + Raises: + FileNotFoundError: if the mapped path does not exist + + Returns: + str: mapped path + """ + filename = None + mapped_addr = None + application_path = os.path.join(path, "application_sources") + + mapped_addr = address_converter_backend(application_path, addr, application_sources_hash_map) #check if the path is in application sources inside the crate + + if mapped_addr: + return mapped_addr # if the path is in application sources then return the path as it is + + if addr.startswith("./"): + addr = addr[1:] + + if not addr.startswith("/"): + addr = "/" + addr + + # Check if the address is a file or a directory and split it accordingly + if addr.endswith("/"): + addr_list = addr.split("/") + else: + addr_list = addr.split("/") + filename = addr_list.pop() + + matched_len = -1 + result_flag = False + + for i in range(len(addr_list)-1,-1,-1): + len_matched_result = 0 + temp_addr_object = None + len_matched_object = 0 + for ls in result_list: + if addr_list[i] in ls: + temp_addr ="" + j= len(ls)-1 + while j>=0 and ls[j] != addr_list[i]: + j-=1 + if j==-1: #nothing matched + break + for k in range(0,j+1): # join the complete matched part + temp_addr = os.path.join(temp_addr, ls[k]) + len_matched_result = j #storing how much comman is addr with any result path + for j in range(i + 1, len(addr_list)): + temp_addr = os.path.join(temp_addr, addr_list[j]) + + if not os.path.exists(f"/{temp_addr}"): + len_matched_result = 0 + + for ls in object_list: + if addr_list[i] in ls: + temp_addr ="" + j= len(ls)-1 + while j>=0 and ls[j] != addr_list[i]: + j-=1 + if j==-1: #nothing matched + break + for k in range(0,j+1): # join the complete matched part + temp_addr = os.path.join(temp_addr, ls[k]) + len_matched_object = j #storing how much comman is addr with any result path + for j in range(i + 1, len(addr_list)): + temp_addr = os.path.join(temp_addr, addr_list[j]) + + if os.path.exists(f"/{temp_addr}"): + temp_addr_object = temp_addr + else: + len_matched_object = 0 + + if len_matched_object==0 and len_matched_result ==0: + continue + + if len_matched_result>len_matched_object:# Assigning the one with more common path + if len_matched_result>matched_len: + matched_len = len_matched_result + mapped_addr = RESULT_PATH + result_flag = True + else: + if len_matched_object>matched_len: + matched_len = len_matched_object + mapped_addr = temp_addr_object + result_flag = False + + if result_flag: + global OUTPUT_NUM + os.makedirs(os.path.join(RESULT_PATH,f"new_output_{OUTPUT_NUM}/"), exist_ok=True) + mapped_addr = os.path.join(RESULT_PATH,f"new_output_{OUTPUT_NUM}/") + OUTPUT_NUM+=1 + return mapped_addr + # If the address is a file, append the filename and check if exists + if mapped_addr and filename: + mapped_addr = os.path.join(mapped_addr, filename) + mapped_addr = "/"+mapped_addr + if not os.path.exists(mapped_addr): + raise FileNotFoundError(f"Could not find the mapped address for: {addr}") + else: + return mapped_addr + + #Could not find such directory or file + if not mapped_addr: + raise FileNotFoundError(f"Could not find the mapped address for: {addr}") + + mapped_addr = "/"+mapped_addr # since in dpf path is always absolute and starts with / + return mapped_addr + +def url_splitter(addr: str)-> list[str]: + """ + Split the given URL into a list of its components. + + Args: + addr (str): The URL to split. + + Returns: + list[str]: A list of the components of the URL. + """ + parsed_url = urlparse(addr) + addr = addr.replace(f"file://{parsed_url.netloc}", "") + if addr.startswith("./"): + addr = addr[1:] + + if not addr.startswith("/"): + addr = "/" + addr + + # Check if the address is a file or a directory and split it accordingly + if addr.endswith("/"): + addr_list = addr.split("/") + else: + addr_list = addr.split("/") + addr_list.pop() + addr_list = addr_list[1:] + + return addr_list + +def command_line_generator_dpf(command: str,path:str) -> list[str]: + """ + Modify the command line arguments to map the paths to the paths + defined inside the ro-crate-metadata.json file. + + Args: + command (str): Original command line string. + path (str): Path to the RO_Crate directory. + + Returns: + list[str]: Modified command line arguments with mapped paths. + """ + crate = ROCrate(path) + objects = get_objects_dict(crate) + results = get_results_dict(crate) + files_a = get_file_names(os.path.join(path, "application_sources")) + application_sources_hashmap = addr_extractor(os.path.join(path, "application_sources")) + result_list = [] + object_list = [] + + for _,val in objects.items(): + object_list.append(url_splitter(val)) + + for _,val in results.items(): + result_list.append(url_splitter(val)) + + object_list = [item for item in object_list if item not in result_list] + + command = shlex.split(command) + flags = [] + paths = [] + values = [] + + for i, cmd in enumerate(command): + if cmd.startswith("--") or cmd.startswith("-"): + if not (cmd.startswith("--provenance") or cmd.startswith("-p")): + flags.append((cmd, i)) + elif re.compile(r'[/\\]').search(cmd): # Pattern for detecting paths + paths.append((cmd, i)) + else: + if cmd in files_a: + values.append((files_a[cmd], i)) + else:# still to verify if the value is a file among the objects + possible = False + value = None + for _, id in objects.items(): + if get_by_id(crate,id)["name"] == cmd: + possible = True + parsed_url = urlparse(id) + # Remove the 'file://' prefix + value = id.replace(f"file://{parsed_url.netloc}", "") + break + if possible: + values.append((value, i)) + else: # else it is considered a value + values.append((cmd, i)) + + new_paths = [] + + for filepath in paths: + new_filepath = address_mapper_dpf(filepath[0], object_list, result_list, application_sources_hashmap, path) + new_paths.append((new_filepath, filepath[1])) + + paths = new_paths + + p1 = 0 + p2 = 0 + + new_command = [] + + while p1 < len(paths) and p2 < len(values): + if paths[p1][1] < values[p2][1]: + new_command.append(paths[p1][0]) + p1 += 1 + else: + new_command.append(values[p2][0]) + p2 += 1 + + while p1 < len(paths): + new_command.append(paths[p1][0]) + p1 += 1 + + while p2 < len(values): + new_command.append(values[p2][0]) + p2 += 1 + + if check_slurm_cluster()[0]: + new_command[0] = "enqueue_compss" + else: + new_command[0] = "runcompss" + + return new_command + +def run_dpf(execution_path:str, crate_path: str) ->bool: + """ + Run the workflow on the cluster in which the dataset paths are available + + Args: + execution_path (str): the path to the execution directory + crate_path (str): the path to the root directory of the RO-Crate + + Returns: + bool: True if the workflow was executed successfully, False otherwise + """ + try: + global RESULT_PATH + RESULT_PATH = os.path.join(execution_path,"Result") + compss_submission_command_path = os.path.join(crate_path, "compss_submission_command_line.txt") + + with open(compss_submission_command_path, 'r', encoding='utf-8') as file: + compss_submission_command = next(file).strip() + + new_command = command_line_generator_dpf(compss_submission_command,crate_path) + # print("New command is:",new_command) + previous_flags = get_previous_flags(crate_path) # get the flags from the previous command + new_command = get_more_flags(new_command, previous_flags) # ask user for more flags he/she wants to add to the final compss command + + new_command = get_change_values(new_command) + + result = executor(new_command,execution_path) + + return result + except Exception as e: + print_colored(e, TextColor.RED) + return False + + diff --git a/compss/tools/reproducibility_service/file_operations.py b/compss/tools/reproducibility_service/file_operations.py new file mode 100644 index 0000000000..c1e31f3d02 --- /dev/null +++ b/compss/tools/reproducibility_service/file_operations.py @@ -0,0 +1,157 @@ +""" +RS File Operations Module + +This module provides functions for handling file operations such as removing temporary files, +moving newly created files to a 'Result' folder, and copying files from specific directories +to the current working directory. + +""" + +import os +import shutil +import datetime + +def move_results_created(initial_files, execution_path: str): + """ + Removes temporary files and moves newly created files to a 'Result' folder. + + Args: + initial_files (set): Set of initial files before operation. + temp (list): List of temporary files to be removed. + + """ + result_folder_path = os.path.join(execution_path, 'Result') + # Get the current list of files in the CWD and remove the clean-up files, that were copied for the execution + cwd = os.getcwd() + current_files = set(os.listdir(cwd)) + # Determine the new files by comparing current files with initial files + new_files = current_files - initial_files + + if new_files: + if not os.path.exists(result_folder_path): + os.makedirs(result_folder_path) + # Move the new files to the Result folder + for new_file in new_files: + if new_file.startswith("reproducibility_service_"): #cannot move the execution directory into itself + continue + if new_file == "__pycache__": # Do not consider the cache files + continue + src_path = os.path.join(cwd, new_file) + dest_path = os.path.join(result_folder_path, new_file) + shutil.move(src_path, dest_path) + +# def remote_dataset_mover(directory: str) -> set[str]: +# """ +# Copies all files from the 'remote_dataset' folder in the current working directory +# to the current working directory. + +# Args: +# directory (str): Path to the 'remote_dataset' directory. + +# Returns: +# set: A set of names of the files and directories copied to the current working directory. +# """ +# remote_dataset_folder = os.path.join(directory, "remote_dataset") +# return copy_all_to_cwd(remote_dataset_folder) + +# def dataset_mover_and_application_mover(crate_directory) -> set[str]: +# """ +# Copies all files from 'application_sources' and 'dataset' folders in the +# crate directory to the current working directory. Can tackle some cases of +# hard-coded paths in the application. + +# Args: +# crate_directory (str): Path to the RO-Crate directory. + +# Returns: +# set: A set of names of the files copied to the current working directory. +# """ +# application_folder = os.path.join(crate_directory, "application_sources") +# dataset_folder = os.path.join(crate_directory, "dataset") +# input_files_copied = set() +# input1 = copy_all_files_to_cwd(application_folder) +# input2 = copy_all_files_to_cwd(dataset_folder) +# input_files_copied = input1.union(input2) + +# return input_files_copied + +def copy_all_files_to_cwd(src_path) -> set[str]: + """ + Copies all files from the specified source path to the current working directory. + + Parameters: + src_path (str): The path to the source directory containing files to be copied. + + Returns: + Set[str]: A set of names of the files copied to the current working directory. + """ + if not os.path.isdir(src_path): + print(f"The provided path '{src_path}' is not a valid directory.") + return set() + + cwd = os.getcwd() + copied_files = set() + + for item in os.listdir(src_path): + src_item_path = os.path.join(src_path, item) + if os.path.isfile(src_item_path): + dst_item_path = os.path.join(cwd, item) + shutil.copy2(src_item_path, dst_item_path) + copied_files.add(os.path.relpath(dst_item_path, cwd)) + + return copied_files + +def copy_all_to_cwd(src_path) -> set[str]: + """ + Copies all files and folders from the specified source path to the current working directory. + + Parameters: + src_path (str): The path to the source directory containing files and folders to be copied. + + Returns: + Set[str]: A set of names of the files and directories copied to the current working directory. + """ + if not os.path.isdir(src_path): + print(f"The provided path '{src_path}' is not a valid directory.") + return set() + + cwd = os.getcwd() + copied_items = set() + + def copy_item(src_item_path, dst_item_path): + if os.path.isdir(src_item_path): + if not os.path.exists(dst_item_path): + os.makedirs(dst_item_path) + items = os.listdir(src_item_path) + for item in items: + copy_item(os.path.join(src_item_path, item), os.path.join(dst_item_path, item)) + else: + shutil.copy2(src_item_path, dst_item_path) + copied_items.add(os.path.relpath(dst_item_path, cwd)) + + for item in os.listdir(src_path): + src_item_path = os.path.join(src_path, item) + dst_item_path = os.path.join(cwd, item) + copy_item(src_item_path, dst_item_path) + + return copied_items + +# def cleanup(temp: set): +# cwd = os.getcwd() +# for filename in temp: +# file_path = os.path.join(cwd, filename) +# if os.path.isfile(file_path): +# os.unlink(file_path) +# elif os.path.isdir(file_path): +# shutil.rmtree(file_path) + +def create_new_execution_directory(SERVICE_PATH: str): + # Create a unique sub-directory name based on the current timestamp + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + new_execution_dir = os.path.join(os.getcwd(), f'reproducibility_service_{timestamp}') + # Create the new sub-directory + os.makedirs(new_execution_dir) + # required directories for the service + os.makedirs(os.path.join(new_execution_dir, 'log')) + os.makedirs(os.path.join(new_execution_dir, 'Workflow')) + return new_execution_dir \ No newline at end of file diff --git a/compss/tools/reproducibility_service/file_verifier.py b/compss/tools/reproducibility_service/file_verifier.py new file mode 100644 index 0000000000..fff69931ac --- /dev/null +++ b/compss/tools/reproducibility_service/file_verifier.py @@ -0,0 +1,111 @@ +""" +RS File Verification Module + +This module verifies files referenced in an RO-Crate against their metadata, +ensuring their existence and optionally their size and modification date. + +""" + +import os + +from rocrate.rocrate import ROCrate +from utils import get_by_id, print_colored, TextColor, generate_file_status_table + +def files_verifier(crate_path: str, instrument: str, objects: dict, remote_dataset_dict: dict): + """ + Verify files within an RO-Crate against their metadata. + + Args: + crate_path (str): Path to the root directory of the RO-Crate. + instrument (str): Identifier of the instrument file within the RO-Crate. + objects (list[str]): List of identifiers for objects/inputs within the RO-Crate. + + Raises: + FileNotFoundError: If any referenced file in the RO-Crate does not exist in the directory. + ValueError:If the content size of any file does not match the recorded size in the RO-Crate. + + Notes: + This function verifies the existence and size of files referenced in the RO-Crate + against the actual files in the specified directory. Optionally, it can also verify + modification dates, although this feature is currently commented out. + """ + print_colored("Verifying the files in the crate", TextColor.YELLOW) + file_verifier = [] # tuple of (file_name, file_path, content_size, actual_size) + verified = True + size_verifier = True + # date_verifier = True + temp_size = [] + temp_path = [] + # temp_date = [] + crate = ROCrate(crate_path) + instrument_path = os.path.join(crate_path, instrument) + instrument_tuple = (instrument, instrument_path, 1, 1) + # Verify the instrument file + if not os.path.exists(instrument_path): + verified = False + temp_path.append(instrument_path) + instrument_tuple = (instrument_tuple[0], instrument_tuple[1], 0, 0) + + if not os.path.getsize(instrument_path) == get_by_id(crate, instrument)["contentSize"]: + size_verifier = False + temp_size.append(instrument_path) + instrument_tuple = (instrument_tuple[0], instrument_tuple[1], instrument_tuple[2], 1) + + file_verifier.append(instrument_tuple) + #Verify the objects/inputs + crate = ROCrate(crate_path) + + if not objects: + print_colored("No objects found in the crate, so nothing to verify", TextColor.GREEN) + return + + for name,input in objects.items(): + if not remote_dataset_dict and name[0] in remote_dataset_dict: # Do not verifiy the local objects if remote dataset exists + continue + # Skip the remote objects + if input.startswith("http"): + continue + file_path = os.path.join(crate_path, input) + file_tuple = (name[0], file_path, 1, 2) + if not os.path.exists(file_path): + verified = False + temp_path.append(file_path) + file_tuple = (name[0], file_path, 0, 0) + file_verifier.append(file_tuple) + continue + # else: + # print(file_path+"\n"+"FILE EXISTS") + file_object = get_by_id(crate, input) + if "contentSize" in file_object: + content_size = file_object["contentSize"] + # Verify the above content size with the actual file size + # Get the actual file size + actual_size = os.path.getsize(file_path) + + # Verify the content size with the actual file size + if actual_size != content_size: + # print(name[0]+"\n"+file_path+"\n"+"SIZE MISMATCH") + size_verifier = False + temp_size.append(os.path.join(crate_path, input)) + file_tuple = (file_tuple[0], file_tuple[1], file_tuple[2], 0) + else: + file_tuple = (file_tuple[0], file_tuple[1], file_tuple[2], 1) + # print(os.path.join(crate_path, input)+"\n"+"SIZE VERIFIED") + + file_verifier.append(file_tuple) + + print_colored("STATUS TABLE (the crate includes the DATASETS needed by the workflow to run, data persistence was TRUE):", TextColor.YELLOW) + + generate_file_status_table(file_verifier, "Included") + + if not size_verifier: + if verified: + raise ValueError(f"Content size mismatch in files: {temp_size}") + else: + raise ValueError(f"Content size mismatch in files: {temp_size}\nFiles missing: {temp_path}") + if not verified: + raise FileNotFoundError(f"Files missing in directory: {temp_path}") + + print_colored("All files in the crate have been verified successfully", TextColor.GREEN) + + diff --git a/compss/tools/reproducibility_service/get_workflow.py b/compss/tools/reproducibility_service/get_workflow.py new file mode 100644 index 0000000000..b1316bc172 --- /dev/null +++ b/compss/tools/reproducibility_service/get_workflow.py @@ -0,0 +1,146 @@ +""" +Get Workflow Module + +Does different operations such as to get the workflow, get more flags, +and change values in the final command + +""" +import os +import shutil +import zipfile +import urllib.parse + +from utils import print_colored,print_colored_ns, TextColor, executor, get_yes_or_no + +def get_workflow(execution_path: str, link_or_path: str) -> str: + """ + Get the workflow from the path or link provided by the user. + If path it may need to extract it to the workflow directory. + + Args: + execution_path (str): The path to the execution directory. + link_or_path (str): The link or path to the workflow. + + Raises: + ValueError: It can occur if the file is not a valid zip file or the link is not a valid URL. + Returns: + str: The path to the workflow + """ + workflow_path = os.path.join(execution_path, "Workflow") + workflow_source = None + + if link_or_path.startswith("http"): + workflow_source = 'link' + else: + workflow_source = 'path' + + if workflow_source == 'path': + print_colored("WARNING: Please ensure this is the path to a zip file or a directory", TextColor.YELLOW) + crate_path = os.path.abspath(link_or_path) + + if zipfile.is_zipfile(crate_path): + shutil.copy(crate_path, os.path.join(workflow_path, "my_crate.zip")) + elif os.path.isdir(crate_path): + return crate_path + else: + raise ValueError(f"The file at path {crate_path} is not a valid") + + elif workflow_source == 'link': + print_colored("WARNING: Please try using the wget command to ensure the link works before submitting the link here.", TextColor.YELLOW) + print_colored("Example: wget -O my_crate.zip https://example.com/my_crate.zip", TextColor.YELLOW) + crate_link = link_or_path + # print("The link to download the crate is:", crate_link) + if not urllib.parse.urlparse(crate_link).scheme in ['http', 'https']: + raise ValueError("The link provided is not a valid URL.") + + executor(["wget", "-O", os.path.join(workflow_path, "my_crate.zip"), crate_link], execution_path) + + else: + raise ValueError("Invalid input. Please enter 'path' or 'link'.") + + crate_zip_path = os.path.join(workflow_path, "my_crate.zip") + + try: + with zipfile.ZipFile(crate_zip_path, 'r') as zip_ref: + zip_ref.extractall(workflow_path) + print(f"The workflow has been successfully extracted to {workflow_path}") + return workflow_path # returns crate path + except zipfile.BadZipFile as e: + raise ValueError(f"The file {crate_zip_path} is not a valid zip file or it is corrupted.") + finally: + os.remove(crate_zip_path) + +def get_more_flags(command: list[str], previous_flags: list[str]) -> list[str]: + """ + Get more flags from the user to add to the final command. + + Args: + command (list[str]): current command + previous_flags (list[str]): old flags as reference + + Returns: + list[str]: new command + """ + print_colored("The current command is as follows:", TextColor.YELLOW) + print_colored(" ".join(command), TextColor.YELLOW) + previous_flags_str = " ".join(previous_flags) + print_colored(f"For Reference) The previously applied flags are as follows: {previous_flags_str}", TextColor.BLUE) + more = get_yes_or_no("Do you want to add more flags to the compss runtime command shown above") + + if not more: # return if no more flags are needed + return command + + print_colored("WARNING: Submit the flags in one go. Example) Please enter the flags you want to add: --lang=python -d -p",TextColor.RED) + flag = input("Please enter the flags you want to add: ") + flags: list[str] = flag.split(" ") + for f in flags: + command.insert(1, f) + + return command + +def get_change_values(command : list[str]) -> list[str]: + """ + Change the values of the final command based on the user input. + + Args: + command (list[str]): The current command + + Raises: + ValueError: If the user enters an invalid integer. + + Returns: + list[str]: The new command + """ + print_colored("The current command is as follows:", TextColor.YELLOW) + print_colored_ns(" ".join(command), TextColor.YELLOW) + if not get_yes_or_no("Do you want to change anything from the above command?"): + return command + else: + n = len(command) + print_colored("The current command indexed is as follows:", TextColor.YELLOW) + for i in range(n): + print_colored_ns(f"{i+1}. {command[i]}", TextColor.YELLOW) + satisfied = True + while satisfied: + try: + m = int(input("How many values do you want to change? Enter the number: ")) + except ValueError: + print("Invalid input. Please enter a valid integer.") + return command + + for i in range(m): + try: + index = int(input(f"Enter the index of the value you want to change (1-{len(command)}): ")) + if index < 1 or index > len(command): + raise ValueError + except ValueError: + print("Invalid input. Please enter a valid integer between 1 and the number of values.") + return command + + command[index-1] = input(f"Enter the new value for index {index}: ").strip() + + for i in range(n): + print_colored_ns(f"{i+1}. {command[i]}", TextColor.YELLOW) + print_colored("Are you happy with the changes above?", TextColor.YELLOW) + satisfied = not get_yes_or_no("") + return command \ No newline at end of file diff --git a/compss/tools/reproducibility_service/new_dataset_backend.py b/compss/tools/reproducibility_service/new_dataset_backend.py new file mode 100644 index 0000000000..10b4c83770 --- /dev/null +++ b/compss/tools/reproducibility_service/new_dataset_backend.py @@ -0,0 +1,50 @@ +""" +New Dataset Backend + +This module asks the user if they want to add the new_dataset and gives the +directory structure of the old dataset as reference. +""" +import os +from utils import print_colored, TextColor, get_yes_or_no + +def print_directory_contents(path:str, level=0): + """ + Print the contents of a directory with indentation. + To show the directory structure in a tree-like format + + Args: + path (str): path to the directory + level (int, optional): Defaults to 0. + """ + try: + # List all the entries in the directory + for entry in os.listdir(path): + entry_path = os.path.join(path, entry) + # Print the entry (file or directory) with indentation + print(' ' * level * 4 + entry) + # If the entry is a directory, recursively call the function for the sub-directory + if os.path.isdir(entry_path): + print_directory_contents(entry_path, level + 1) + except PermissionError: + # Handle the case where the program does not have permission to access the directory + print(' ' * level * 4 + '[Permission Denied]') + + +def new_dataset_info_collector(crate_directory:str): + """ + Collect information about the new dataset. + Whether the user wants to add a new dataset or not. + + Args: + crate_directory (str): the path to the root directory of the RO-Crate. + """ + new_dataset_path = os.path.join(crate_directory, "new_dataset") + os.makedirs(new_dataset_path) + print("\nPlease copy the new dataset to the 'new_dataset' folder :\n") + print_colored("New dataset path: " + new_dataset_path, TextColor.BLUE) + print_colored("WARNING| MAKE SURE THE NEW DATASET FOLLOWS THE SAME DIRECTORY STRUCTURE AS THE OLD DATASET", TextColor.RED) + print("The old directory structure for reference is as follows:\n") + print_directory_contents(os.path.join(crate_directory, "dataset")) + check = False + while not check: + check = get_yes_or_no("Have you copied the new dataset to the 'new_dataset' folder?") diff --git a/compss/tools/reproducibility_service/provenance_backend.py b/compss/tools/reproducibility_service/provenance_backend.py new file mode 100644 index 0000000000..dc9bf9b2e0 --- /dev/null +++ b/compss/tools/reproducibility_service/provenance_backend.py @@ -0,0 +1,102 @@ +""" +RS provenance flag logic Module + +This module gets used in case the user triggers the provenance flag. It does the necessary +steps to generate the provenence for COMPSs Runtime + +""" +import os +import time + +from rocrate.rocrate import ROCrate +from ruamel.yaml import YAML +from utils import get_instument, get_yes_or_no, get_name_and_description, get_ro_crate_info, print_colored, TextColor + +def update_yaml(crate_path: str): + """ + Update the 'ro-crate-info.yaml' file with workflow metadata. + + Args: + crate_path (str): Path to the root directory of the RO-Crate. + + Raises: + FileNotFoundError: If the specified files or directories do not exist. + + Notes: + This function updates the 'ro-crate-info.yaml' file with metadata such as sources, + main file, name, and description retrieved from the RO-Crate. + """ + crate = ROCrate(crate_path) + instrument = get_instument(crate) + sources_main_file = os.path.join(crate_path, instrument) + sources = os.path.join(crate_path, "application_sources") + name, description, authors = get_name_and_description(crate_path) + # Create a YAML instance + yaml = YAML() + yaml.preserve_quotes = True + yaml_file_path = os.path.join(os.getcwd(), "ro-crate-info.yaml") + # Read the YAML content from the file + with open(yaml_file_path, 'r', encoding='utf-8') as file: + data = yaml.load(file) + + # Update the name and description fields + # Update the fields in the loaded YAML content + data['COMPSs Workflow Information']['sources'] = sources + data['COMPSs Workflow Information']['sources_main_file'] = sources_main_file + data['COMPSs Workflow Information']['name'] = name + data['COMPSs Workflow Information']['description'] = description + data['Authors'] = authors + + # Ask for submitter details + print_colored("Please provide the submitter's detail for provenance generation: ", TextColor.YELLOW) + submitter_details = data['Submitter'] + submitter_details['name'] = input("Submitter's Name [Name]: ").strip() or "Name" + submitter_details['e-mail'] = input("Submitter's E-mail [submitter@email.com]: ").strip() or "submitter@email.com" + submitter_details['orcid'] = input("Submitter's ORCID [https://orcid.org/XXXX-XXXX-XXXX-XXXX]: ").strip() or "https://orcid.org/XXXX-XXXX-XXXX-XXXX" + submitter_details['organisation_name'] = input("Submitter's Organisation Name [Submitter Institution name]: ").strip() or "Submitter Institution name" + submitter_details['ror'] = input("Submitter's ROR [https://ror.org/XXXXXXXXX]: ").strip() or "https://ror.org/XXXXXXXXX" + + # Write the updated dictionary back to the YAML file + with open(yaml_file_path, 'w', encoding='utf-8') as file: + yaml.dump(data, file) + + print("Updated the ro-crate-info.yaml file with the workflow information.") + +def provenance_info_collector(execution_path:str, service_path: str) -> bool: + """ + Collect provenance information for the workflow based on user input. + + Returns: + bool: True if provenance collection is enabled; False otherwise. + + Notes: + This function prompts the user to confirm if they want to collect provenance information. + If confirmed, it checks for the existence of 'ro-crate-info.yaml' file and prompts the user + to ensure it is filled correctly. If not found or verified, it invokes 'get_ro_crate_info' + to generate the file. It returns True if provenance collection is enabled, False otherwise. + """ + provenance_flag = get_yes_or_no("Do you want to generate the provenance of your workflow run?") + # print("Provenance_flag:",provenance_flag) + if provenance_flag: + files = os.listdir(os.getcwd()) + already_exists = "ro-crate-info.yaml" in files + if not already_exists: + get_ro_crate_info(execution_path, service_path) + + return provenance_flag + +def provenance_checker(execution_path: str) : + # for file in os.listdir(os.getcwd()): + # if file == "ro-crate-info.yaml": + # os.unlink(os.path.join(os.getcwd(), file)) + # break + result_path = os.path.join(execution_path, 'Result') + if not os.path.exists(result_path): + contains_crate = False + else: + contains_crate = any(name.startswith('COMPSs_RO-Crate_') for name in os.listdir(result_path) if os.path.isdir(os.path.join(result_path, name))) + + if contains_crate: + print_colored(f"RO_CRATE has been generated successfully inside {result_path}", TextColor.GREEN) + else: + print_colored("Could not generate the RO_CRATE for provenance, please see the above provenance log for more details", TextColor.RED) diff --git a/compss/tools/reproducibility_service/pseudocode.txt b/compss/tools/reproducibility_service/pseudocode.txt new file mode 100644 index 0000000000..51925ed33a --- /dev/null +++ b/compss/tools/reproducibility_service/pseudocode.txt @@ -0,0 +1,54 @@ +""" +Just a pseudocode to show the main steps of the workflow. This is not a real code, and it is not intended to be executed. +""" +# Getting the crate +link = ask_user_for_ro-crate_link: +if link.startswith('http'): + download_and_unzip in Workflow/ + crate_path = Workflow/ +elif zipfile.is_zipfile(link): + unzip in Workflow/ + crate_path = Workflow/ +elif os.path.isdir(link): + check_that_exists(ro-crate-metadata.json, application_sources/, compss_submission_command_line.txt, any_name.yaml) # os.path.isdir and isfile + crate_path = link # Use the original crate folder, without modifying it +else: + report_error_wrong_crate + + +# Preparing files and verifiying +if data_persistence: + check_that_exists(dataset/) # os.path.isdir + verified = verify_all_object_files_from_ro-crate with dataset/ + # If the crate was already a dir in the local machine, not only contentSize, but also dateModified can be verified. Rest of cases, only contentSize. + if not verified: # Check if any files still not verified can be found in the remote datasets. This code could go inside verify_all_object_files_from_ro-crate + if remote_files_exist: + download_files to remote_datasets/ + verified = verify_missing_files with remote_dataset/ + if not verified: + error_report_files_missing_or_wrong + +if not data_persistence: + print CreateAction["name"] # Show the user, where the original run was made + verified = verify_all_object_files_from_ro-crate with their_original_paths # Check if they can be accessed, exist, and have the same contentSize and dateModified + if not verified: + repeat_remote_files_steps_from above # download and verify missing files + if not verified: + error_report_files_missing_or_wrong + + +# Resubmission +if verified: + match = check_compss_version # compare installed COMPSs version with the one used in the ro-crate-metadata.json + if not match: + print_warning to the user + + build_new_command # but leaving outputs in Results/ NOT overwriting the original or downloaded crate + print_used_flags_for_the_run + if flags_must_be_changed: + ask_the_user_for_the_new_flags + resubmit_application # Use runcompss if no SLURM is detected (can be tested running 'squeue'), enqueue_compss if SLURM is detecte + + +# Possible final step for the future +check_all_result_files_from_ro-crate # diff files at CreateAction["result"] to the new results created by the application \ No newline at end of file diff --git a/compss/tools/reproducibility_service/remote_dataset.py b/compss/tools/reproducibility_service/remote_dataset.py new file mode 100644 index 0000000000..b1256a989d --- /dev/null +++ b/compss/tools/reproducibility_service/remote_dataset.py @@ -0,0 +1,55 @@ +"""_ +Remote Dataset Module + +This module is used to download the remote datasets mentioned in the metadata file +and verify their size. +""" +import os +import sys + +from rocrate.rocrate import ROCrate +from utils import download_file, get_Create_Action, print_colored, TextColor, get_by_id + + +def remote_dataset(crate: ROCrate,crate_directory: str) -> bool: + """ + Download the remote datasets mentioned in the metadata file and verify their size. + Args: + crate (ROCrate): ROCrate object. + crate_directory (str): the path to the root directory of the RO-Crate. + + Returns: + bool: True if the remote datasets exist, False otherwise. + """ + + create_action = get_Create_Action(crate) + remote_datasets= {} + if "object" in create_action: + temp = create_action["object"] + else: + return (False, {}) + + for input in temp: # get the remote_datasets mapped with their names + if (input.id).startswith("http"): + remote_datasets[input["name"]] = input.id + + for key,val in remote_datasets.items(): # download the remote datasets with the specified names + try: + print_colored(f"Please wait while remote file {key} is being downloaded from {val} ...", TextColor.YELLOW) + download_file(val, os.path.join(crate_directory,"remote_dataset") + ,key) + + if "contentSize" in get_by_id(crate,val): + if get_by_id(crate,val)["contentSize"] == os.path.getsize(os.path.join(crate_directory,"remote_dataset",key)): + print_colored(f"Remote file {key} has been successfully downloaded with size verified.", TextColor.GREEN) + else: + print_colored(f"Remote file {key} has been successfully downloaded.Could not verify size as it is not mentioned in the metadata", TextColor.GREEN) + + except ValueError: + print(f"Remote dataset {key} could not be downloaded.") + sys.exit(1) + + if len(remote_datasets) == 0: + return (False, {}) + + return (True, remote_datasets) diff --git a/compss/tools/reproducibility_service/reproducibility_methods/__init__.py b/compss/tools/reproducibility_service/reproducibility_methods/__init__.py new file mode 100644 index 0000000000..132643492b --- /dev/null +++ b/compss/tools/reproducibility_service/reproducibility_methods/__init__.py @@ -0,0 +1 @@ +from .generate_command_line import generate_command_line diff --git a/compss/tools/reproducibility_service/reproducibility_methods/address_mapper.py b/compss/tools/reproducibility_service/reproducibility_methods/address_mapper.py new file mode 100644 index 0000000000..43ccefe675 --- /dev/null +++ b/compss/tools/reproducibility_service/reproducibility_methods/address_mapper.py @@ -0,0 +1,147 @@ +""" +Address Mapper Module + +This module provides functionality to map and convert addresses of datasets +within a given directory structure. It includes the following functions: + +# Problems: +# IF THE DIRECTORY IS TAKEN IN THE FORM OF DATA/INPUT INSTED OF DATA/INPUT/ +# THEN IT WILL NOT BE ABLE TO FIND THE DIRECTORY +# DO NOT INCLUDE DOUBLE SLASHES IN THE PATHS LIKE INPUT//TEXT.TXT IT WILL NOT FIND IT +""" + +import os + +# TO-DO: +# change address converter backend such that if a file/directory matches with +# any result object then mak a new folder with same name if directory inside the Results/ and map it there +# else it is assumed to be a application source or a dataset file/dir +def address_converter_backend(path: str, addr: str, dataset_hashmap: dict) -> str: + """ + Converts the given address to a mapped address inside the RO_Crate + based on the dataset hashmap. + + Args: + path (str): The base path of the dataset. + addr (str): The address to be converted. + dataset_hashmap (dict): A dictionary containing the mapping of dataset addresses. + + Returns: + str: The mapped address corresponding to the given address. + + Raises: + FileNotFoundError: If the mapped address for the given address is not found. + """ + filename = None + mapped_addr = None + + if addr.startswith("./"): + addr = addr[1:] + + if not addr.startswith("/"): + addr = "/" + addr + + # Check if the address is a file or a directory and split it accordingly + if addr.endswith("/"): + addr_list = addr.split("/") + else: + addr_list = addr.split("/") + filename = addr_list.pop() + + for i in range(1, len(addr_list)): + if addr_list[i] in dataset_hashmap: + temp_addr = os.path.join(path, addr_list[i]) + for j in range(i + 1, len(addr_list)): + temp_addr = os.path.join(temp_addr, addr_list[j]) + + if os.path.exists(temp_addr): + mapped_addr = temp_addr + break + + # If the address is a file, append the filename and check if exists + if filename: + if not mapped_addr: + mapped_addr = path + mapped_addr = os.path.join(mapped_addr, filename) + if not os.path.exists(mapped_addr): + raise FileNotFoundError(f"Could not find the mapped address for: {addr}") + + # Could not find such directory or file + if not mapped_addr: + raise FileNotFoundError(f"Could not find the mapped address for: {addr}") + + return mapped_addr + +def address_converter(path: str, addr: str, dataset_hashmap: dict, + application_sources_hashmap: dict,remote_dataset_hashmap:dict, dataset_flags: tuple[bool, bool]) -> str: + """ + Attempts to convert the given address first using the dataset hashmap and + then using the application sources hashmap. Raises a `FileNotFoundError` if + the address cannot be mapped in either case. + + Args: + path (str): Path to the RO_Crate directory. + addr (str): Address to be converted. + dataset_hashmap (dict): Hashmap generated from addr_extractor. + application_sources_hashmap (dict): Hashmap generated from addr_extractor. + remote_dataset_hashmap (dict): Hashmap generated from addr_extractor. + dataset_flags (tuple[bool, bool]): (remote_dataset_flag, new_dataset_flag) + + Raises: + FileNotFoundError: Cannot find the address inside the RO_Crate. + + Returns: + str: The mapped address. + """ + errors = [] + + if dataset_flags[1]: + dataset_path = os.path.join(path, "new_dataset") + else: + dataset_path = os.path.join(path, "dataset") + application_sources_path = os.path.join(path, "application_sources") + + # Define the paths to try for address conversion + paths_to_try = [ + (dataset_path, dataset_hashmap, "Dataset Error"), + (application_sources_path, application_sources_hashmap, "Application Sources Error") + ] + + if dataset_flags[0]: + paths_to_try.insert(0,(os.path.join(path, "remote_dataset"), remote_dataset_hashmap, "Remote Dataset Error")) + + for path, hashmap, error_context in paths_to_try: # try all the paths one by one and return where + try: # file is found,if not found append the error to the list + return address_converter_backend(path, addr, hashmap) + except FileNotFoundError as e: + errors.append((error_context, e)) + + handle_address_conversion_failure(addr, errors) +def addr_extractor(path: str) -> dict: + """ + Extracts the addresses of datasets in the given path. For this particular case, + it is used to extract the mapping of filenames in the crate/dataset and + crate/application_sources directories. + + Args: + path (str): The path to the directory containing the datasets. + + Returns: + dict: A dictionary mapping dataset filenames to a value of 1. + """ + if not os.path.exists(path): + os.makedirs(path) + hash_map = {} + for filename in os.listdir(path): + hash_map[filename] = 1 + + return hash_map + +def handle_address_conversion_failure(addr:str, errors: list): + """ + Used for raising a `FileNotFoundError` when the address conversion fails. + """ + error_messages = "\n".join(f"{context}: {err}" for context, err in errors) + raise FileNotFoundError( + f"Could not find the mapped address for: {addr}\n{error_messages}" + ) from errors[-1][1] \ No newline at end of file diff --git a/compss/tools/reproducibility_service/reproducibility_methods/generate_command_line.py b/compss/tools/reproducibility_service/reproducibility_methods/generate_command_line.py new file mode 100644 index 0000000000..0ea41b1e88 --- /dev/null +++ b/compss/tools/reproducibility_service/reproducibility_methods/generate_command_line.py @@ -0,0 +1,185 @@ +""" +Command Line Generator Module + +This module provides functions to generate and modify command line arguments, +particularly handling paths for datasets and application sources. + +Problems: +Anything with r'/' ot r'\' is considered a path, but it could be a flag or a value +Plus if the command is " runcompss main.py " it will not convert the path for main.py +so it must be declared as " runcompss /main.py " +""" + +import os +import shlex +import re + +from rocrate.rocrate import ROCrate +from .address_mapper import address_converter, addr_extractor +from .utilsr import get_file_names, get_results_dict, check_slurm_cluster + +def generate_command_line(self, sub_directory_path:str) -> list[str]: + """ + Generates a modified command line based on the contents of a compss_submission_command_line.txt + file and the mappings of dataset and application sources in the crate directory. + + Args: + crate_directory (str): Path to the crate directory containing dataset and application sources. + + Returns: + list[str]: Modified command line arguments. + """ + # print('\nParsing metadata from: ', self.crate_directory) + path = self.crate_directory + dataset_flags = (self.remote_dataset_flag, self.new_dataset_flag) + + remote_dataset_hashmap = {} + if self.remote_dataset_flag: + remote_dataset_hashmap = addr_extractor(os.path.join(path, "remote_dataset")) + + if not self.new_dataset_flag: + dataset_hashmap = addr_extractor(os.path.join(path, "dataset")) + else: + dataset_hashmap = addr_extractor(os.path.join(path, "new_dataset")) + + application_sources_hashmap = addr_extractor(os.path.join(path, "application_sources")) + + compss_submission_command_path = os.path.join(path, "compss_submission_command_line.txt") + + with open(compss_submission_command_path, 'r', encoding='utf-8') as file: + compss_submission_command = next(file).strip() + + new_command = command_line_generator(compss_submission_command, path, + dataset_hashmap, application_sources_hashmap,remote_dataset_hashmap,dataset_flags,self.remote_dataset_flag, sub_directory_path) + + return new_command + +def commonsuffix(path1:str,path2:str): + paths = [path1, path2] + # Reverse the strings in the list to use os.path.commonprefix on the reversed strings + reversed_paths = [os.path.dirname(path)[::-1] for path in paths] # This removes the ending '/' from the directories + # Find the common prefix of the reversed strings + reversed_common_prefix = os.path.commonprefix(reversed_paths) + # Reverse the common prefix back to get the common suffix + path1 = path1.split("/") + is_possible = reversed_common_prefix[::-1] in path1 # check if the commaon dir + if is_possible: + return os.path.basename(reversed_common_prefix[::-1]) + else: + return None + +def is_result(filepath:str,results_dict:dict, sub_directory_path:str)->str: + + result_path = os.path.join(sub_directory_path, "Result") + if not os.path.exists(result_path): + os.mkdir(result_path) + if os.path.basename(filepath): #if the path is a file + if os.path.basename(filepath) in results_dict: #if it is a result + return os.path.join(result_path,os.path.basename(filepath)) + else: # if path is a directory + for _, id in results_dict.items(): + is_possible_result = commonsuffix(filepath, id) + if is_possible_result: + # print("Result is possible for ", filepath, id) + # print(f"Commonsuffix: {is_possible_result}") + is_possible_result += '/' + if not os.path.exists(os.path.join(result_path, is_possible_result)): + os.mkdir(os.path.join(result_path, is_possible_result)) + return os.path.join(result_path, is_possible_result) + + return None # if it is not a result + +def command_line_generator(command: str, path: str, dataset_hashmap: dict, + application_sources_hashmap: dict, remote_dataset_hashmap: dict, dataset_flags: tuple[bool,bool],remote_dataset_flag:bool, sub_directory_path:str) -> list[str]: + """ + Generates a modified command line by replacing paths in the command with their + mapped counterparts based on the dataset and application sources mappings. + Acts as the main logic behind generate_command_line. + + Args: + command (str): Original command line string. + path (str): Path to the RO_Crate directory. + dataset_hashmap (dict): Hashmap generated from addr_extractor. + application_sources_hashmap (dict): Hashmap generated from addr_extractor. + + Returns: + list[str]: Modified command line arguments with mapped paths. + """ + command = shlex.split(command) + flags = [] + paths = [] + values = [] + # print(1) + # print(path) + files_a = get_file_names(os.path.join(path, "application_sources")) + files_d = get_file_names(os.path.join(path, "dataset")) + files_r = get_file_names(os.path.join(path, "remote_dataset")) + + crate = ROCrate(path) + results_dict = get_results_dict(crate) + + for i, cmd in enumerate(command): + if cmd.startswith("--") or cmd.startswith("-"): + if not (cmd.startswith("--provenance") or cmd.startswith("-p")): + flags.append((cmd, i)) + elif re.compile(r'[/\\]').search(cmd): # Pattern for detecting paths + paths.append((cmd, i)) + else: + if results_dict and cmd in results_dict: + result_path = os.path.join(sub_directory_path, "Result") + if not os.path.exists(result_path): + os.mkdir(result_path) + values.append((os.path.join(result_path, cmd), i)) + elif remote_dataset_flag and cmd in files_r: + values.append((files_r[cmd], i)) + elif cmd in files_a: + values.append((files_a[cmd], i)) + elif cmd in files_d: + values.append((files_d[cmd], i)) + else: + values.append((cmd, i)) + + new_paths = [] + + for filepath in paths: + pathr = is_result(filepath[0], results_dict, sub_directory_path) + + if pathr: # if it is a result then it is specially mapped inside Result/ in the sub-dir + new_paths.append((pathr, filepath[1])) + + else: # it is treated as a normal path inside application_sources or dataset + new_filepath = address_converter(path, filepath[0], dataset_hashmap, + application_sources_hashmap, remote_dataset_hashmap, dataset_flags) + new_paths.append((new_filepath, filepath[1])) + + + paths = new_paths + + p1 = 0 + p2 = 0 + + new_command = [] + # for this to work paths and value must be sorted by index + values = sorted(values, key=lambda x: x[1]) # needed to add this because of appending result paths at the end + while p1 < len(paths) and p2 < len(values): + if paths[p1][1] < values[p2][1]: + new_command.append(paths[p1][0]) + p1 += 1 + else: + new_command.append(values[p2][0]) + p2 += 1 + + while p1 < len(paths): + new_command.append(paths[p1][0]) + p1 += 1 + + while p2 < len(values): + new_command.append(values[p2][0]) + p2 += 1 + + if check_slurm_cluster()[0]: + new_command[0] = "enqueue_compss" + else: + new_command[0] = "runcompss" + + return new_command \ No newline at end of file diff --git a/compss/tools/reproducibility_service/reproducibility_methods/utilsr.py b/compss/tools/reproducibility_service/reproducibility_methods/utilsr.py new file mode 100644 index 0000000000..c19aa4664e --- /dev/null +++ b/compss/tools/reproducibility_service/reproducibility_methods/utilsr.py @@ -0,0 +1,58 @@ +""" +Utils Module for reproducibility_methods module + +""" +import os +import subprocess + +from rocrate.rocrate import ROCrate + +def get_file_names(folder_path: str) -> dict: + """ + Get the file names in the folder path + """ + file_names = {} + for root,_,files in os.walk(folder_path): + for file in files: + file_names[file] = os.path.join(root, file) + return file_names + + +def get_Create_Action(entity:ROCrate): + """ + Get the Create Action entity from the ROCrate + """ + for entity in entity.get_entities(): + if entity.type == "CreateAction": + return entity + return None + +def get_results_dict(entity:ROCrate): + """ + Get the results dictionary from the Create Action entity + """ + createAction = get_Create_Action(entity) + results= {} + if "result" in createAction: # It is not necessary to have inputs/objects in Create Action + temp = createAction["result"] + else: + return None + + for result in temp: + results[result["name"]] = result.id + return results + +def check_slurm_cluster() -> tuple[bool, str]: + """ + To check if the program is running on a SLURM cluster. + + Returns: + tuple[bool, str]: tuple of a boolean indicating if the program + is running on a SLURM cluster and a message. + """ + try: + result = subprocess.run(['squeue'], capture_output=True, text=True) + if result.returncode == 0: + return True, result.stdout + except Exception as e: + return False, str(e) diff --git a/compss/tools/reproducibility_service/utils.py b/compss/tools/reproducibility_service/utils.py new file mode 100644 index 0000000000..f9d0b42b92 --- /dev/null +++ b/compss/tools/reproducibility_service/utils.py @@ -0,0 +1,539 @@ +""" +Utils Module + +It contains utility functions that are used in the main script or other modules. +""" +import os +import yaml +import shutil +import subprocess +import threading +import time +import urllib.request +import zipfile + +from ruamel.yaml import YAML +from rocrate.rocrate import ROCrate +from tabulate import tabulate + + +class TextColor: + RED = '\033[91m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + RESET = '\033[0m' + +def print_colored(text, color): + """ + To print colored text in the console. + Args: + text (str): The text to be printed. + color (str): The color to be used for printing the text. + """ + print(f"\n{color}{text}{TextColor.RESET}\n") + +def print_colored_ns(text, color): + """ + To print colored text in the console without new line. + Args: + text (str): The text to be printed. + color (str): The color to be used for printing the text. + """ + print(f"{color}{text}{TextColor.RESET}") + +def print_welcome_message(): + """ + To print the welcome message in the console. + """ + welcome_text = """ + ╔════════════════════════════════════════════════════╗ + ║ ║ + ║ Welcome to COMPSS Reproducibility ║ + ║ Service v1.0.0 ║ + ║ ║ + ║ COMPSS Version: 3.3.1 ║ + ║ ║ + ║ Ensuring reproducibility in computational ║ + ║ workflows with precision and reliability. ║ + ║ ║ + ║ Let's make your computations reproducible! ║ + ║ ║ + ╚════════════════════════════════════════════════════╝ + """ + print_colored(welcome_text, TextColor.GREEN) # sleep for 1s for the user to see this + time.sleep(1) + +def get_by_id(entity:ROCrate, id:str): + """ + To parse the ROCrate and get the entity with the specified ID. + Args: + entity (ROCrate): The ROCrate object. + id (str): The ID of the entity to be retrieved. + + Returns: + _type_: None if not found else the entity with the specified ID. + """ + # Loop through all entities in the RO-Crate + for entity in entity.get_entities(): + if entity.id == id: + return entity + return None + +def get_Create_Action(crate:ROCrate): + """ + To get the CreateAction entity from the ROCrate. + Args: + entity (ROCrate): The ROCrate object. + + Returns: + _type_: None if not found else the CreateAction entity. + """ + # Loop through all entities in the RO-Crate + for entity in crate.get_entities(): + if entity.type == "CreateAction": + return entity + return None + +def get_instument(entity:ROCrate): + """ + To get the instrument ID from the CreateAction entity. + Args: + entity (ROCrate): The ROCrate object. + + Returns: + _type_: The ID of the instrument. + """ + createAction = get_Create_Action(entity) + return createAction["instrument"].id + +def get_objects(entity:ROCrate) -> list[str]: + """ + To get the objects from the CreateAction entity. + Args: + entity (ROCrate): The ROCrate object. + + Returns: + _type_: A list of object IDs. + """ + createAction = get_Create_Action(entity) + objects = [] + if "object" in createAction: + # It is not necessary to have inputs/objects in Create Action + temp = createAction["object"] + else: + return objects # Empty + + for val in temp: + if "hasPart" in val: + for has in val["hasPart"]: + objects.append(has.id) + else: + objects.append(val.id) + return objects + +def get_results_dict(entity:ROCrate)->dict: + """ + To get the results from the CreateAction entity. + Args: + entity (ROCrate): The ROCrate object. + + Returns: + _type_: A dictionary mapped from the result name to the result ID. + """ + createAction = get_Create_Action(entity) + results= {} + if "result" in createAction: + # It is not necessary to have inputs/objects in Create Action + temp = createAction["result"] + else: + return results # Empty + + for result in temp: + results[result["name"]] = result.id + return results + + +def get_objects_dict(entity:ROCrate)->dict: + """ + To get the objects from the CreateAction entity. + Args: + entity (ROCrate): The ROCrate object. + + Returns: + dict:A dict of (name,id) -> id , so that it does'nt collide with the same name + """ + createAction = get_Create_Action(entity) + objects= {} + if "object" in createAction: + # It is not necessary to have inputs/objects in Create Action + temp = createAction["object"] + else: + return objects # Empty + + for input in temp: + if "hasPart" in input: + # if hasPart exists, it means it is a composite object + for has in input["hasPart"]: + objects[(has["name"],has.id)] = has.id + else: + objects[(input["name"],input.id)] = input.id + # else it is just a single object + return objects + +def get_create_action_name(entity: ROCrate) -> str: + """ + Gets the COMPSs execution details in CreateAction["name"]. + Args: + entity (ROCrate): The ROCrate object. + + Returns: + str: The CreateAction["name"]. + """ + createAction = get_Create_Action(entity) + return createAction["name"] + + +def key_exists_with_first_element(d, first_element): + return any(key[0] == first_element for key in d) + +def get_file_names(folder_path: str) -> dict: + """ + To get the file names from the specified folder path. + Args: + folder_path (str): path to the folder. + + Returns: + dict: dictionary of file names and their full paths. + """ + file_names = {} + for root, dirs, files in os.walk(folder_path): + for file in files: + file_names[file] = os.path.join(root, file) + return file_names + +def get_compss_crate_version(crate_path: str) -> str: + """ + Gets the COMPSs version used to create the ROCrate. + Args: + crate_path (str): The path to the ROCrate. + + Returns: + float: The version of COMPSs used to create the ROCrate. + """ + crate = ROCrate(crate_path) + compss_object = get_by_id(crate,"#compss") + return compss_object["version"] + + +def get_yes_or_no(msg :str) : + """ + To get the user input as 'y' or 'n'. + Args: + msg (str): The message outputed to the user + + Returns: + _type_: True if 'y' else False if 'n'. + """ + while True: + user_input = input(f"{msg} (y/n):").lower() + if user_input == 'y' or user_input == 'n': + if user_input == 'y': + return True + else: + return False + else: + print("Invalid input. Please enter 'y' or 'n'.") + +def get_data_persistence_status(crate_path:str) -> bool: + """ + To get data_persistence status from ro-crate-yaml file. + Args: + crate_path (str): Path for the crate directory. + Raises: + FileNotFoundError: If ro-crate-info.yaml file not found in the crate. + + Returns: + bool: True if data_persistence is True else False. + """ + #It may not be named ro-crate-info.yaml, eg: 838-1 crate + yaml_file_path = None + for name in os.listdir(crate_path): + if name.endswith(".yaml"): + yaml_file_path = f"{crate_path}/{name}" + break + if not yaml_file_path: + raise FileNotFoundError("YAML file not found in the crate") + # Open and read the YAML file + with open(yaml_file_path, 'r') as file: + # Load the content of the YAML file + config = yaml.safe_load(file) + # Extract the value of data_persistence + data_persistence = config.get('COMPSs Workflow Information', + {}).get('data_persistence', None) + + return data_persistence + +def get_name_and_description(crate_path: str) -> tuple: + """ + To get the name and description from the ro-crate-info.yaml file. + Args: + crate_path (str): Path to the crate directory. + + Returns: + tuple: returns tuple of name, description and authors. + """ + #It may not be named ro-crate-info.yaml, eg: 838-1 crate + yaml_file_path = None + for name in os.listdir(crate_path): + if name.endswith(".yaml"): + yaml_file_path = f"{crate_path}/{name}" + break + if not yaml_file_path: + raise Exception("ro-crate-info.yaml file not found in the crate") + # Open and read the YAML file + with open(yaml_file_path, 'r') as file: + # Load the content of the YAML file + data = YAML().load(file) + + # Extract name and description + name = data['COMPSs Workflow Information'].get('name', '') + description = data['COMPSs Workflow Information'].get('description', '') + authors = data['Authors'] + + return name, description, authors + +def get_ro_crate_info(execution_path: str, service_path: str): + """ + Copies a ro-crate-info.yaml files to the current working directory. + + """ + source = os.path.join(service_path,"APP-REQ/ro-crate-info.yaml") + # Get the current working directory + cwd = os.getcwd() + file_name = "ro-crate-info.yaml" + # Construct the full destination path + destination = os.path.join(cwd, file_name) + + try: + # Copy the file to the current working directory + shutil.copy(source, destination) + #print(f'ro-crate-info.yaml file copied to the current working directory.') + except Exception as e: + print(f'Error copying ro-crate-info.yaml file from {source}: {e}') + + +def executor(command: list[str], execution_path: str) : + """ + Uses subprocess librray to execute the command and logs the output to a file. + Args: + command (list[str]): list of str containing the command to be executed + execution_path (str): path to the execution directory + + Returns: + _type_: True if the command executed successfully else False. + """ + joined_command = " ".join(command) + print_colored(f"Executing command: {joined_command}", TextColor.BLUE) + + # Create log directory if it doesn't exist + log_dir = os.path.join(execution_path, "log") + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # Define log file names + stdout_log = os.path.join(log_dir, "out.log") + stderr_log = os.path.join(log_dir, "err.log") + + # Start the subprocess + process = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + + # Define functions to read and log output + def read_stdout(pipe, log_file): + with open(log_file, 'a') as f: + for line in iter(pipe.readline, ''): + print(line.strip()) + f.write(line) + pipe.close() + + def read_stderr(pipe, log_file): + with open(log_file, 'a') as f: + for line in iter(pipe.readline, ''): + print(line.strip()) + f.write(line) + pipe.close() + + # Create threads to read and log stdout and stderr + stdout_thread = threading.Thread(target=read_stdout, args=(process.stdout, stdout_log)) + stderr_thread = threading.Thread(target=read_stderr, args=(process.stderr, stderr_log)) + + stdout_thread.start() + stderr_thread.start() + + # Wait for the process to complete + process.wait() + + # Ensure all output has been logged + stdout_thread.join() + stderr_thread.join() + + # Print log file locations + print(f"Standard output logged to: {stdout_log}") + print(f"Standard error logged to: {stderr_log}") + + # Check the return code + if process.returncode == 0: + print("Command executed successfully.") + return True + else: + print("Command failed with return code:", process.returncode) + return False + +def download_file(url: str , download_path: str, file_name: str): + """ + Downloads a file from the specified URL and saves it to the specified path. + Args: + url (str): The URL of the file to be downloaded. + download_path (str): The path where the file should be saved. + file_name (str): The name of the file to be saved. + """ + if not os.path.exists(download_path): + os.makedirs(download_path) + # Create the full path to the file + full_path = os.path.join(download_path, file_name) + print_colored(f"Downloading {file_name} from {url} to {full_path}, please wait...", TextColor.YELLOW) + # Download the file and save it to the specified path + urllib.request.urlretrieve(url, full_path) + print(f"File downloaded as {full_path}") + # Check if the file is a zip file and extract it + if zipfile.is_zipfile(full_path): + with zipfile.ZipFile(full_path, 'r') as zip_ref: + zip_ref.extractall(download_path) + print(f"Extracted {file_name} in {download_path}") + + # Remove the zip file after extraction + os.remove(full_path) + print(f"Removed the zip file {file_name}") + +def check_compss_version()-> str: + """ + To check the version of COMPSs installed on the system. + Returns: + float: The version of COMPSs installed on the system. + """ + try: + # Execute the command + result = subprocess.run(['runcompss', '-v'], capture_output=True, text=True, check=True) + + # Parse the output + output = result.stdout.strip() + if "COMPSs version" in output: + version = output.split('COMPSs version ')[1].split(" ")[0] + print(f"COMPSs Version Found: {version}") + return version + else: + return "COMPSs version not found in the output." + + except subprocess.CalledProcessError as e: + return f"An error occurred while trying to get COMPSs version: {e}" + except FileNotFoundError: + return "runcompss command not found. Please ensure that COMPSs is installed and the command is available in your PATH." + +def check_slurm_cluster() -> tuple[bool, str]: + """ + To check if the program is running on a SLURM cluster. + + Returns: + tuple[bool, str]: tuple of a boolean indicating if the program + is running on a SLURM cluster and a message. + """ + try: + result = subprocess.run(['squeue'], capture_output=True, text=True) + if result.returncode == 0: + return True, result.stdout + except Exception as e: + return False, str(e) + + return False, "squeue command failed without raising an exception" + +def get_previous_flags(crate_path: str) -> list[str]: + """ + To get the previous flags used in the COMPSs submission command. + + Args: + crate_path (str): The path to the crate directory. + + Returns: + list[str]: A list of the previous flags used in the COMPSs submission command. + """ + compss_submission_command_path = os.path.join(crate_path, "compss_submission_command_line.txt") + + with open(compss_submission_command_path, 'r', encoding='utf-8') as file: + command = next(file).strip() + previous_flags = [] + for cmd in command.split(): + if cmd.startswith("--") or cmd.startswith("-"): + if not (cmd.startswith("--provenance") or cmd.startswith("-p")): + previous_flags.append(cmd) + + return previous_flags + +def print_symbol_reference(): + """ + To print the reference line for the symbols used in the file status table + """ + references = { + '✔': 'SUCCESS', + '✘': 'FAILURE', + '–': 'NOT IN METADATA' + } + + # Create a single line of references + reference_line = ' | '.join(f"{symbol}: {meaning}" for symbol, meaning in references.items()) + + print(reference_line) + +# Function to convert status codes to symbols +def get_status_symbol(file_exists, file_size_verified): + """ + To convert the status codes to symbols. + """ + exists_symbol = "✅" if file_exists == 1 else "❌" + size_verified_symbol = "✅" if file_size_verified == 1 else "❌" if file_size_verified == 0 else "—" + return exists_symbol, size_verified_symbol + +# Function to wrap the file path based on a length limit +def wrap_text(text, width): + """ + To wrap the text based on the specified width. + """ + return '\n'.join([text[i:i+width] for i in range(0, len(text), width)]) + +# Function to generate the table +def generate_file_status_table(file_status_list,Third_field:str, path_width_limit=40): + """ + To generate a table to display the file status. + Args: + file_status_list (): list of tuples containing the file status information. + Third_field (str): The name of the third field in the table. + """ + table = [] + # Adding header row + table.append(["", "Metadata File Name", "Host File Path", Third_field , "Size"]) + + # Adding file status rows + for i, (filename, file_path, file_exists, file_size_verified) in enumerate(file_status_list, start=1): + exists_symbol, size_verified_symbol = get_status_symbol(file_exists, file_size_verified) + + # Wrap the file path if it exceeds the specified width limit + wrapped_file_path = wrap_text(file_path, path_width_limit) + wrapped_filename = wrap_text(filename, path_width_limit) + + table.append([i, wrapped_filename, wrapped_file_path, exists_symbol, size_verified_symbol]) + + # Print the table + print(tabulate(table, headers="firstrow", tablefmt="grid")) + print_symbol_reference() + + From 6d12aabe954881a61cb249d898343a1c1dbfbbcc Mon Sep 17 00:00:00 2001 From: Minimega12121 Date: Fri, 20 Sep 2024 00:01:52 +0530 Subject: [PATCH 2/3] Updated chameleon scripts --- .../scripts/system/chameleon/chameleon_init | 4 +- .../scripts/utils/chameleon_cluster_setup | 103 +++++++++--------- 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/compss/runtime/scripts/system/chameleon/chameleon_init b/compss/runtime/scripts/system/chameleon/chameleon_init index 305227069c..c0b74de508 100755 --- a/compss/runtime/scripts/system/chameleon/chameleon_init +++ b/compss/runtime/scripts/system/chameleon/chameleon_init @@ -13,10 +13,10 @@ extra_configure() { ____ ___ __ __ ____ ____ / ___/ _ \\| \\/ | _ \\/ ___| ___ | | | | | | |\\/| | |_) \\___ \\/ __| -| |__| |_| | | | | __/ ___) \\__ \\ +| |__| |_| | | | | __/ ___) \\__ \\ \\ \\____\\___/|_| |_|_| |____/|___/ -Welcome to COMPSs v2.1 at Chameleon! +Welcome to COMPSs v3.1 at Chameleon! EOT echo "127.0.1.1 COMPSsMaster" >> /etc/hosts diff --git a/compss/runtime/scripts/utils/chameleon_cluster_setup b/compss/runtime/scripts/utils/chameleon_cluster_setup index cad6ad8883..0398e500de 100755 --- a/compss/runtime/scripts/utils/chameleon_cluster_setup +++ b/compss/runtime/scripts/utils/chameleon_cluster_setup @@ -1,4 +1,4 @@ -#!/bin/bash -e + #!/bin/bash -e #=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # @@ -12,20 +12,14 @@ #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- - # Setting up COMPSs_HOME - if [ -z "${COMPSS_HOME}" ]; then - COMPSS_HOME="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/../../.. && pwd )/" - fi - if [ ! "${COMPSS_HOME: -1}" = "/" ]; then - COMPSS_HOME="${COMPSS_HOME}/" - fi - export COMPSS_HOME=${COMPSS_HOME} + # Setting up COMPSS_HOME + export COMPSS_HOME="/opt/COMPSs/" ########################################################## # Script variables user=cc instanceCreationTime=10 # Iterations over 30s - sshUpTime=8 # Iterations over 30s + sshUpTime=8 # Iterations over 30s randomID=$RANDOM tmpFile=/tmp/compss-workers-${randomID}.tmp HALF_MIN=30s @@ -38,30 +32,29 @@ sleep 2s # Prompt messages to get information - echo "Provide the name of the COMPSs Master Instance (this instance):" - read -r masterName - echo "Provide the reservation ID to deploy COMPSs:" - read -r reservationId - echo "Provide the number of COMPSs Workers:" - read -r numWorkers - echo " " + read -rp "Provide the name of the COMPSs Master Instance (this instance): " masterName + read -rp "Provide the reservation ID to deploy COMPSs: " reservationId + read -rp "Provide the number of COMPSs Workers: " numWorkers + echo "Type 1 if connected via fabnetv4 network otherwise type 2 if connected via sharednet1" + read choice + + NETWORK="sharednet1" ########################################################## # Retrieve other information echo "* Retrieving configuration parameters from Chameleon..." - image=$(nova show "$masterName" | grep image | tr "|" "\\t" | awk '{ print $2 }') - netId=$(neutron net-list | grep sharednet1 | tr "|" "\\t" | awk '{ print $1 }') - + image=$(openstack server show "$masterName" -f value -c image | awk '{print $2}' | sed 's/[()]//g') + netId=$(openstack network list | grep "$NETWORK" | awk '{print $2}') ########################################################## - # Launch workers + # Launch workers echo "* Launching workers..." # Insert COMPSs Master key to OpenStack. Create workers with COMPSsMaster key authorized - nova keypair-add --pub_key /home/cc/.ssh/id_rsa.pub COMPSsMaster${randomID} + openstack keypair create --public-key /home/cc/.ssh/id_rsa.pub COMPSsMaster${randomID} # Create workers for (( i=1; i<=numWorkers; i++ )); do - cmd="nova boot --flavor baremetal --image $image --key-name COMPSsMaster${randomID} --nic net-id=$netId --hint reservation=$reservationId COMPSsWorker$i" + cmd="openstack server create --flavor baremetal --image $image --key-name COMPSsMaster${randomID} --nic net-id=$netId --hint reservation=$reservationId COMPSsWorker$i" echo "$cmd" $cmd sleep $SLEEP_BETWEEN_WORKER_CREATION @@ -78,15 +71,13 @@ for (( i=1; i<=numWorkers; i++ )); do # Wait for each worker - cmd_status=$(nova list | grep "COMPSsWorker$i" | tr "|" "\\t" | awk '{ print $3 }') + cmd_status=$(openstack server list | grep "COMPSsWorker$i" | awk '{print $6}') while [ "$cmd_status" != "ACTIVE" ]; do sleep ${HALF_MIN} - cmd_status=$(nova list | grep "COMPSsWorker$i" | tr "|" "\\t" | awk '{ print $3 }') + cmd_status=$(openstack server list | grep "COMPSsWorker$i" | awk '{print $6}') done echo " - COMPSsWorker$i is ACTIVE" done - - ########################################################## # Retrieving COMPSs Workers information echo "* Retrieving COMPSs Workers information..." @@ -94,18 +85,36 @@ echo "# Automatically added hostnames by chameleon_cluster_setup" > $tmpFile workerIPs="" for (( i=1; i<=numWorkers; i++ )); do - workerIP=$(nova show COMPSsWorker$i | grep "network" | tr "|" "\\t" | awk '{ print $3 }' | tr "," "\\t" | awk '{ print $1 }') + workerIP=$(openstack server show COMPSsWorker$i -f value -c addresses | tr ',' '\n' | grep -oP '\d+\.\d+\.\d+\.\d+' | head -n 1) # Update worker list workerIPs="$workerIPs $workerIP" - # Update hosts tmp file" - echo "$workerIP COMPSsWorker$i" >> $tmpFile + # Update hosts tmp file + echo "$workerIP COMPSsWorker$i" >> $tmpFile # Log worker IP echo " - COMPSsWorker$i has IP = $workerIP" done + echo "Debugging Information:" + echo "user=$user" + echo "instanceCreationTime=$instanceCreationTime" + echo "sshUpTime=$sshUpTime" + echo "randomID=$randomID" + echo "tmpFile=$tmpFile" + echo "HALF_MIN=$HALF_MIN" + echo "SLEEP_BETWEEN_WORKER_CREATION=$SLEEP_BETWEEN_WORKER_CREATION" + echo "masterName=$masterName" + echo "reservationId=$reservationId" + echo "numWorkers=$numWorkers" + echo "NETWORK=$NETWORK" + echo "image=$image" + echo "netId=$netId" + echo "workerIPS=$workerIPs" + echo " " + + # Adding configuration to COMPSs Master /etc/hosts file - sudo /bin/bash -c "cat $tmpFile >> /etc/hosts" - masterIP=$(nova show "$masterName" | grep "network" | tr "|" "\\t" | awk '{ print $3 }' | tr "," "\\t" | awk '{ print $1 }') + sudo bash -c "cat $tmpFile >> /etc/hosts" + masterIP=$(openstack server show "$masterName" -f value -c addresses | tr ',' '\n' | grep -oP '\d+\.\d+\.\d+\.\d+' | head -n 1) echo "$masterIP COMPSsMaster" >> $tmpFile # Configuring COMPSs Workers @@ -117,17 +126,14 @@ printf "\\n" for workerIP in $workerIPs; do - scp -o StrictHostKeyChecking=no $tmpFile $user@"$workerIP":$tmpFile - # shellcheck disable=SC2029 - ssh -t -t -o StrictHostKeyChecking=no -o BatchMode=yes -o ChallengeResponseAuthentication=no $user@"$workerIP" "sudo /bin/bash -c 'cat $tmpFile >> /etc/hosts'" - # shellcheck disable=SC2029 - ssh -t -t -o StrictHostKeyChecking=no -o BatchMode=yes -o ChallengeResponseAuthentication=no $user@"$workerIP" "rm -f $tmpFile" - done + scp -o StrictHostKeyChecking=no $tmpFile $user@$workerIP:$tmpFile + ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ChallengeResponseAuthentication=no $user@$workerIP "sudo bash -c 'cat $tmpFile >> /etc/hosts'" + ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ChallengeResponseAuthentication=no $user@$workerIP "rm -f $tmpFile" + done # Clean tmpfile rm -f $tmpFile - ########################################################## # Update COMPSs project / resources files echo "* Updating COMPSs project and resources files..." @@ -135,14 +141,11 @@ resources="${COMPSS_HOME}Runtime/configuration/xml/resources/default_resources.xml" echo "" - echo "Provide the application path:" - read -r appDir + read -rp "Provide the application path: " appDir # # PROJECT.XML # - # shellcheck source=../system/xmls/generate_project.sh - # shellcheck disable=SC1091 source "${COMPSS_HOME}Runtime/scripts/system/xmls/generate_project.sh" # Init project file @@ -150,10 +153,11 @@ # Add header (from generate_project.sh) add_header # Add master information (from generate_project.sh) - add_master_node "" + add_master_node 4 1 0 16 "" # Add workers (from generate_project.sh) for (( i=1; i<=numWorkers; i++ )); do - add_compute_node "COMPSsWorker$i" "/opt/COMPSs/" "/tmp/COMPSsWorker$i" "$user" "$appDir" "" "" "" "" + echo "$i" + add_compute_node "COMPSsWorker$i" "/opt/COMPSs/" "/tmp/COMPSsWorker$i" "$user" "$appDir" "" "" "" "" done # Close project (from generate_project.sh) add_footer @@ -161,24 +165,21 @@ # # RESOURCES.XML # - # shellcheck source=../system/xmls/generate_resources.sh - # shellcheck disable=SC1091 source "${COMPSS_HOME}Runtime/scripts/system/xmls/generate_resources.sh" - + # Init resources file init "${resources}" # Add header (from generate_resources.sh) add_header # Add workers for (( i=1; i<=numWorkers; i++ )); do - add_compute_node "COMPSsWorker$i" "24" "0" "0" "125" "43001" "43102" "" "" + echo "$i" + add_compute_node "COMPSsWorker$i" "24" "0" "125" "43001" "43102" "" "" done # Close resources (from generate_resources.sh) add_footer - ########################################################## # End echo "SUCCESS!" exit - From e9409ac19aec9a9c0f17da5a1c343d7cfba7af73 Mon Sep 17 00:00:00 2001 From: Minimega12121 Date: Wed, 2 Oct 2024 00:27:46 +0530 Subject: [PATCH 3/3] docs: changes to README --- compss/tools/reproducibility_service/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/compss/tools/reproducibility_service/README.md b/compss/tools/reproducibility_service/README.md index afa669721d..48155440a0 100644 --- a/compss/tools/reproducibility_service/README.md +++ b/compss/tools/reproducibility_service/README.md @@ -50,5 +50,20 @@ This is an automatic reproducibility service designed to help reproduce COMPSs w 3. The `data_persistence = False` examples are only supposed to work on the original SLURM cluster where paths related to the experiment are accessible (i.e. the new Submitter may need to request access permissions). --- +### How to Use via Chameleon +If you're unsure how to create an instance on Chameleon, please refer to the official documentation: [Chameleon Documentation](https://chameleoncloud.readthedocs.io/en/latest/index.html). + +To utilize this service or run any COMPSs experiments, you can create an instance of the Ubuntu 22.04 appliance with COMPSs 3.3.1 pre-installed. You can find the appliance here: [Ubuntu 22.04 with COMPSs 3.3.1](https://www.chameleoncloud.org/appliances/121/). + +After successfully creating an instance of the appliance, execute the following command to set up the environment: +```bash +sudo ./working_scripts/basic_config.sh start +``` + +Once the setup is complete, you can proceed to run any COMPSs experiments of your choice. + +> **Note:** Since Chameleon allows access to remote networks, you can directly clone the Reproducibility Service as well as the RO-Crate of the experiment you want to reproduce. + +--- I hope you find this service helpful!