From 2c1545c6ec04eb875636d0b17d202ea00bcd3868 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Mon, 6 Dec 2021 13:51:37 +0100 Subject: [PATCH 01/57] add conda upload script --- main/tools/conda/create.sh | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 main/tools/conda/create.sh diff --git a/main/tools/conda/create.sh b/main/tools/conda/create.sh new file mode 100644 index 00000000..8c8b195e --- /dev/null +++ b/main/tools/conda/create.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +anaconda login + +anaconda upload */dist/idds*-0.9.1.tar.gz + From c712827d066ff4147ce5323c1e2bdc71b8fc6b7f Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 01:38:27 +0100 Subject: [PATCH 02/57] fix to release doma jobs --- doma/lib/idds/doma/workflow/domapandawork.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doma/lib/idds/doma/workflow/domapandawork.py b/doma/lib/idds/doma/workflow/domapandawork.py index 7c733130..63520c9d 100644 --- a/doma/lib/idds/doma/workflow/domapandawork.py +++ b/doma/lib/idds/doma/workflow/domapandawork.py @@ -804,6 +804,9 @@ def get_job_maps(self, input_output_maps): def get_update_contents(self, inputnames, inputname_mapid_map, inputname_jobid_map): self.logger.debug("get_update_contents, inputnames[:5]: %s" % str(inputnames[:5])) + self.logger.debug("get_update_contents, inputname_mapid_map[:5]: %s" % str({k: inputname_mapid_map[k] for k in inputnames[:5]})) + self.logger.debug("get_update_contents, inputname_jobid_map[:5]: %s" % str({k: inputname_jobid_map[k] for k in inputnames[:5]})) + update_contents = [] num_updated_contents, num_unupdated_contents = 0, 0 for inputname in inputnames: @@ -835,12 +838,16 @@ def get_update_contents(self, inputnames, inputname_mapid_map, inputname_jobid_m pass # content['content_metadata']['panda_id'] = panda_id content['substatus'] = panda_status + else: + content['content_metadata']['panda_id'] = panda_id + content['substatus'] = panda_status update_contents.append(content) num_updated_contents += 1 else: num_unupdated_contents += 1 self.logger.debug("get_update_contents, num_updated_contents: %s, num_unupdated_contents: %s" % (num_updated_contents, num_unupdated_contents)) + self.logger.debug("get_update_contents, update_contents[:5]: %s" % (str(update_contents[:5]))) return update_contents def poll_panda_task(self, processing=None, input_output_maps=None): From 783f556515dd588fdb60e637e1f2f94de545aee6 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 01:41:48 +0100 Subject: [PATCH 03/57] fix to release doma jobs --- doma/lib/idds/doma/workflowv2/domapandawork.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doma/lib/idds/doma/workflowv2/domapandawork.py b/doma/lib/idds/doma/workflowv2/domapandawork.py index dab509b0..f8272b3a 100644 --- a/doma/lib/idds/doma/workflowv2/domapandawork.py +++ b/doma/lib/idds/doma/workflowv2/domapandawork.py @@ -809,6 +809,9 @@ def get_job_maps(self, input_output_maps): def get_update_contents(self, inputnames, inputname_mapid_map, inputname_jobid_map): self.logger.debug("get_update_contents, inputnames[:5]: %s" % str(inputnames[:5])) + self.logger.debug("get_update_contents, inputname_mapid_map[:5]: %s" % str({k: inputname_mapid_map[k] for k in inputnames[:5]})) + self.logger.debug("get_update_contents, inputname_jobid_map[:5]: %s" % str({k: inputname_jobid_map[k] for k in inputnames[:5]})) + update_contents = [] num_updated_contents, num_unupdated_contents = 0, 0 for inputname in inputnames: @@ -840,12 +843,16 @@ def get_update_contents(self, inputnames, inputname_mapid_map, inputname_jobid_m pass # content['content_metadata']['panda_id'] = panda_id content['substatus'] = panda_status + else: + content['content_metadata']['panda_id'] = panda_id + content['substatus'] = panda_status update_contents.append(content) num_updated_contents += 1 else: num_unupdated_contents += 1 self.logger.debug("get_update_contents, num_updated_contents: %s, num_unupdated_contents: %s" % (num_updated_contents, num_unupdated_contents)) + self.logger.debug("get_update_contents, update_contents[:5]: %s" % (str(update_contents[:5]))) return update_contents def poll_panda_task(self, processing=None, input_output_maps=None): From 92e258cd8f1d55b751b09f0c98de2335ff46d0d0 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 01:52:51 +0100 Subject: [PATCH 04/57] fix to release doma jobs --- doma/lib/idds/doma/workflow/domapandawork.py | 33 ++++++++++--------- .../lib/idds/doma/workflowv2/domapandawork.py | 33 ++++++++++--------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/doma/lib/idds/doma/workflow/domapandawork.py b/doma/lib/idds/doma/workflow/domapandawork.py index 63520c9d..31ecea06 100644 --- a/doma/lib/idds/doma/workflow/domapandawork.py +++ b/doma/lib/idds/doma/workflow/domapandawork.py @@ -759,21 +759,24 @@ def poll_panda_jobs(self, job_ids): chunks = [job_ids[i:i + chunksize] for i in range(0, len(job_ids), chunksize)] for chunk in chunks: jobs_list = Client.getJobStatus(chunk, verbose=0)[1] - self.logger.debug("poll_panda_jobs, input jobs: %s, output_jobs: %s" % (len(chunk), len(jobs_list))) - for job_info in jobs_list: - job_status = self.get_content_status_from_panda_status(job_info) - if job_info and job_info.Files and len(job_info.Files) > 0: - for job_file in job_info.Files: - # if job_file.type in ['log']: - if job_file.type not in ['pseudo_input']: - continue - if ':' in job_file.lfn: - pos = job_file.lfn.find(":") - input_file = job_file.lfn[pos + 1:] - # input_file = job_file.lfn.split(':')[1] - else: - input_file = job_file.lfn - inputname_jobid_map[input_file] = {'panda_id': job_info.PandaID, 'status': job_status} + if jobs_list: + self.logger.debug("poll_panda_jobs, input jobs: %s, output_jobs: %s" % (len(chunk), len(jobs_list))) + for job_info in jobs_list: + job_status = self.get_content_status_from_panda_status(job_info) + if job_info and job_info.Files and len(job_info.Files) > 0: + for job_file in job_info.Files: + # if job_file.type in ['log']: + if job_file.type not in ['pseudo_input']: + continue + if ':' in job_file.lfn: + pos = job_file.lfn.find(":") + input_file = job_file.lfn[pos + 1:] + # input_file = job_file.lfn.split(':')[1] + else: + input_file = job_file.lfn + inputname_jobid_map[input_file] = {'panda_id': job_info.PandaID, 'status': job_status} + else: + self.logger.warn("poll_panda_jobs, input jobs: %s, output_jobs: %s" % (len(chunk), jobs_list)) return inputname_jobid_map def get_job_maps(self, input_output_maps): diff --git a/doma/lib/idds/doma/workflowv2/domapandawork.py b/doma/lib/idds/doma/workflowv2/domapandawork.py index f8272b3a..8966b83b 100644 --- a/doma/lib/idds/doma/workflowv2/domapandawork.py +++ b/doma/lib/idds/doma/workflowv2/domapandawork.py @@ -764,21 +764,24 @@ def poll_panda_jobs(self, job_ids): chunks = [job_ids[i:i + chunksize] for i in range(0, len(job_ids), chunksize)] for chunk in chunks: jobs_list = Client.getJobStatus(chunk, verbose=0)[1] - self.logger.debug("poll_panda_jobs, input jobs: %s, output_jobs: %s" % (len(chunk), len(jobs_list))) - for job_info in jobs_list: - job_status = self.get_content_status_from_panda_status(job_info) - if job_info and job_info.Files and len(job_info.Files) > 0: - for job_file in job_info.Files: - # if job_file.type in ['log']: - if job_file.type not in ['pseudo_input']: - continue - if ':' in job_file.lfn: - pos = job_file.lfn.find(":") - input_file = job_file.lfn[pos + 1:] - # input_file = job_file.lfn.split(':')[1] - else: - input_file = job_file.lfn - inputname_jobid_map[input_file] = {'panda_id': job_info.PandaID, 'status': job_status} + if jobs_list: + self.logger.debug("poll_panda_jobs, input jobs: %s, output_jobs: %s" % (len(chunk), len(jobs_list))) + for job_info in jobs_list: + job_status = self.get_content_status_from_panda_status(job_info) + if job_info and job_info.Files and len(job_info.Files) > 0: + for job_file in job_info.Files: + # if job_file.type in ['log']: + if job_file.type not in ['pseudo_input']: + continue + if ':' in job_file.lfn: + pos = job_file.lfn.find(":") + input_file = job_file.lfn[pos + 1:] + # input_file = job_file.lfn.split(':')[1] + else: + input_file = job_file.lfn + inputname_jobid_map[input_file] = {'panda_id': job_info.PandaID, 'status': job_status} + else: + self.logger.warn("poll_panda_jobs, input jobs: %s, output_jobs: %s" % (len(chunk), jobs_list)) return inputname_jobid_map def get_job_maps(self, input_output_maps): From 62f0abc10f4469c2a43aec8453b93c551a3b8dcc Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 20:52:02 +0100 Subject: [PATCH 05/57] fix num_run --- workflow/lib/idds/workflowv2/work.py | 16 ++++++++-------- workflow/lib/idds/workflowv2/workflow.py | 9 +++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/workflow/lib/idds/workflowv2/work.py b/workflow/lib/idds/workflowv2/work.py index b4f7937f..6b04f250 100644 --- a/workflow/lib/idds/workflowv2/work.py +++ b/workflow/lib/idds/workflowv2/work.py @@ -542,7 +542,7 @@ def __init__(self, executable=None, arguments=None, parameters=None, setup=None, self.backup_to_release_inputs = {'0': [], '1': [], '2': []} - self.num_run = None + self.num_run = 0 """ self._running_data_names = [] @@ -930,16 +930,16 @@ def to_update_processings(self, value): @property def num_run(self): - return self.get_metadata_item('num_run', None) + return self.get_metadata_item('num_run', 0) @num_run.setter def num_run(self, value): - if value is not None: - self.add_metadata_item('num_run', value) - if value > 1: - # for k in self._collections: - for coll in self.output_collections: - if type(coll) in [Collection]: + self.add_metadata_item('num_run', value) + if value is not None and value > 1: + # for k in self._collections: + for coll in self.output_collections: + if type(coll) in [Collection]: + if "___idds___" not in coll.name: coll.name = coll.name + "." + str(value) @property diff --git a/workflow/lib/idds/workflowv2/workflow.py b/workflow/lib/idds/workflowv2/workflow.py index 915d87c4..610b89f9 100644 --- a/workflow/lib/idds/workflowv2/workflow.py +++ b/workflow/lib/idds/workflowv2/workflow.py @@ -990,12 +990,11 @@ def to_update_transforms(self, value): @property def num_run(self): - return self.get_metadata_item('num_run', None) + return self.get_metadata_item('num_run', 0) @num_run.setter def num_run(self, value): - if value is not None: - self.add_metadata_item('num_run', value) + self.add_metadata_item('num_run', value) def load_metadata(self): self.load_works() @@ -1071,10 +1070,10 @@ def get_new_work_to_run(self, work_id, new_parameters=None): work.set_parameters(new_parameters) work.sequence_id = self.num_total_works + work.num_run = self.num_run work.initialize_work() work.sync_global_parameters(self.global_parameters) work.renew_parameters_from_attributes() - work.num_run = self.num_run works = self.works self.works = works # self.work_sequence.append(new_work.get_internal_id()) @@ -2031,6 +2030,8 @@ def sync_works(self): p_metadata = self.runs[str(self.num_run - 1)].get_metadata_item('parameter_links') self.runs[str(self.num_run)].add_metadata_item('parameter_links', p_metadata) + self.runs[str(self.num_run)].global_parameters = self.runs[str(self.num_run - 1)].global_parameters + def get_relation_map(self): if not self.runs: return [] From 11a31c967cabbeb5dfd0c01c52ac1f3ab24b7c98 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 20:53:01 +0100 Subject: [PATCH 06/57] fix renew global parameters --- .../lib/idds/atlas/workflowv2/atlaslocalpandawork.py | 1 + atlas/lib/idds/atlas/workflowv2/atlaspandawork.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/atlas/lib/idds/atlas/workflowv2/atlaslocalpandawork.py b/atlas/lib/idds/atlas/workflowv2/atlaslocalpandawork.py index 6bd4e01c..2bec4ca0 100644 --- a/atlas/lib/idds/atlas/workflowv2/atlaslocalpandawork.py +++ b/atlas/lib/idds/atlas/workflowv2/atlaslocalpandawork.py @@ -86,6 +86,7 @@ def parse_task_parameters(self, task_parameters): self.add_errors(str(ex)) def renew_parameters_from_attributes(self): + super(ATLASLocalPandaWork, self).renew_parameters_from_attributes() if not self.task_parameters: return diff --git a/atlas/lib/idds/atlas/workflowv2/atlaspandawork.py b/atlas/lib/idds/atlas/workflowv2/atlaspandawork.py index 78682696..05a566f4 100644 --- a/atlas/lib/idds/atlas/workflowv2/atlaspandawork.py +++ b/atlas/lib/idds/atlas/workflowv2/atlaspandawork.py @@ -207,9 +207,14 @@ def renew_parameters_from_attributes(self): return try: + for key in self.task_parameters: + if self.task_parameters[key] and type(self.task_parameters[key]) in [str]: + self.task_parameters[key] = self.renew_parameter(self.task_parameters[key]) + if 'taskName' in self.task_parameters: self.task_name = self.task_parameters['taskName'] self.task_name = self.renew_parameter(self.task_name) + self.task_parameters['taskName'] = self.task_name self.set_work_name(self.task_name) if 'prodSourceLabel' in self.task_parameters: @@ -222,6 +227,13 @@ def renew_parameters_from_attributes(self): for key in jobP: if jobP[key] and type(jobP[key]) in [str]: jobP[key] = self.renew_parameter(jobP[key]) + + if 'log' in self.task_parameters: + log = self.task_parameters['log'] + for key in log: + if log[key] and type(log[key]) in [str]: + self.task_parameters['log'][key] = self.renew_parameter(log[key]) + for coll_id in self.collections: coll_name = self.collections[coll_id].name self.collections[coll_id].name = self.renew_parameter(coll_name) From 276b696c3c1bd242f19fc49fec79b9a4ffdc552a Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 20:54:14 +0100 Subject: [PATCH 07/57] update cfg template --- main/etc/idds/idds.cfg.template | 2 ++ .../idds/rest/httpd-idds-443-py39-cc7.conf.template | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/main/etc/idds/idds.cfg.template b/main/etc/idds/idds.cfg.template index 92ff9590..f892895b 100755 --- a/main/etc/idds/idds.cfg.template +++ b/main/etc/idds/idds.cfg.template @@ -81,6 +81,8 @@ poll_time_period = 5 retrieve_bulk_size = 10 message_bulk_size = 2000 +atlaslocalpandawork.work_dir = /data/idds_processing + [conductor] retrieve_bulk_size = 10 plugin.notifier = idds.atlas.notifier.messaging.MessagingSender diff --git a/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template b/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template index 4214f207..277f7f21 100644 --- a/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template +++ b/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template @@ -6,8 +6,8 @@ # Authors: # - Wen Guan, , 2019 -# TimeOut 600 -# KeepAliveTimeout 600 +TimeOut 600 +KeepAliveTimeout 600 # Built-in modules LoadModule ssl_module /usr/lib64/httpd/modules/mod_ssl.so @@ -23,7 +23,6 @@ LoadModule ssl_module /usr/lib64/httpd/modules/mod_ssl.so # External modules LoadModule gridsite_module /usr/lib64/httpd/modules/mod_gridsite.so #LoadModule wsgi_module /usr/lib64/httpd/modules/mod_wsgi.so -LoadModule wsgi_module {python_site_packages_path}/mod_wsgi/server/mod_wsgi-py36.cpython-36m-x86_64-linux-gnu.so LoadModule wsgi_module {python_site_packages_path}/mod_wsgi/server/mod_wsgi-py39.cpython-39-x86_64-linux-gnu.so WSGIPythonHome {python_site_home_path} @@ -93,9 +92,9 @@ Alias "/monitor" "/opt/idds/monitor" - Order deny,allow - Allow from all - Require all granted + # Order deny,allow + # Allow from all + # Require all granted From 2e950cff3754ddf5398e7d169e60f7fb0b839e5c Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 7 Dec 2021 20:54:52 +0100 Subject: [PATCH 08/57] update tools --- main/lib/idds/tests/core_tests.py | 4 ++-- main/lib/idds/tests/panda_test.py | 2 +- main/lib/idds/tests/test_migrate_requests.py | 2 +- main/setup.py | 2 +- main/tools/env/install_idds_full.sh | 8 ++++++-- main/tools/env/setup_dev.sh | 2 ++ 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/main/lib/idds/tests/core_tests.py b/main/lib/idds/tests/core_tests.py index 8dd444aa..cdea6309 100644 --- a/main/lib/idds/tests/core_tests.py +++ b/main/lib/idds/tests/core_tests.py @@ -102,7 +102,7 @@ def show_works(req): print(work_ids) -reqs = get_requests(request_id=229, with_detail=True, with_metadata=True) +reqs = get_requests(request_id=237, with_detail=True, with_metadata=True) for req in reqs: # print(req['request_id']) # print(rets) @@ -126,7 +126,7 @@ def show_works(req): """ -tfs = get_transforms(request_id=230) +tfs = get_transforms(request_id=238) for tf in tfs: # print(tf) # print(tf['transform_metadata']['work'].to_dict()) diff --git a/main/lib/idds/tests/panda_test.py b/main/lib/idds/tests/panda_test.py index 8b59f959..c1e20d4e 100644 --- a/main/lib/idds/tests/panda_test.py +++ b/main/lib/idds/tests/panda_test.py @@ -29,7 +29,7 @@ print(f.type) """ -jediTaskID = 7784 +jediTaskID = 8378 ret = Client.getJediTaskDetails({'jediTaskID': jediTaskID}, True, True, verbose=False) print(ret) diff --git a/main/lib/idds/tests/test_migrate_requests.py b/main/lib/idds/tests/test_migrate_requests.py index 8c532214..f16d3a27 100644 --- a/main/lib/idds/tests/test_migrate_requests.py +++ b/main/lib/idds/tests/test_migrate_requests.py @@ -31,7 +31,7 @@ def migrate(): cm1 = ClientManager(host=dev_host) # reqs = cm1.get_requests(request_id=290) - old_request_id = 225 + old_request_id = 237 # for old_request_id in [152]: # for old_request_id in [60]: # noqa E115 # for old_request_id in [200]: # noqa E115 diff --git a/main/setup.py b/main/setup.py index 8e1afc03..560378b2 100644 --- a/main/setup.py +++ b/main/setup.py @@ -103,7 +103,7 @@ def replace_data_path(wsgi_file, install_data_path): install_home_path = get_python_home() install_data_path = get_data_path() -rest_conf_files = ['etc/idds/rest/httpd-idds-443-py36-cc7.conf.template'] +rest_conf_files = ['etc/idds/rest/httpd-idds-443-py39-cc7.conf.template'] replace_python_path(rest_conf_files, install_lib_path, install_bin_path, install_home_path) wsgi_file = 'bin/idds.wsgi.template' replace_data_path(wsgi_file, install_data_path) diff --git a/main/tools/env/install_idds_full.sh b/main/tools/env/install_idds_full.sh index 43eca3af..3ef23f23 100644 --- a/main/tools/env/install_idds_full.sh +++ b/main/tools/env/install_idds_full.sh @@ -3,8 +3,8 @@ # as root yum install -y httpd.x86_64 conda gridsite mod_ssl.x86_64 httpd-devel.x86_64 gcc.x86_64 supervisor.noarch # yum install -y gfal2-plugin-gridftp gfal2-plugin-file.x86_64 gfal2-plugin-http.x86_64 gfal2-plugin-xrootd.x86_64 gfal2-python.x86_64 gfal2-python3.x86_64 gfal2-all.x86_64 -conda install -c conda-forge python-gfal2 -pip install requests SQLAlchemy urllib3 retrying mod_wsgi flask futures stomp.py cx-Oracle unittest2 pep8 flake8 pytest nose sphinx recommonmark sphinx-rtd-theme nevergrad +# conda install -c conda-forge python-gfal2 +# pip install requests SQLAlchemy urllib3 retrying mod_wsgi flask futures stomp.py cx-Oracle unittest2 pep8 flake8 pytest nose sphinx recommonmark sphinx-rtd-theme nevergrad mkdir /opt/idds mkdir /opt/idds_source @@ -22,9 +22,13 @@ git clone @github_idds@ /opt/idds_source conda env create --prefix=/opt/idds -f main/tools/env/environment.yml # source /etc/profile.d/conda.sh conda activate /opt/idds +conda install -c conda-forge python-gfal2 + pip install rucio-clients-atlas rucio-clients panda-client # root ca.crt to /opt/idds/etc/ca.crt +pip install requests SQLAlchemy urllib3 retrying mod_wsgi flask futures stomp.py cx-Oracle unittest2 pep8 flake8 pytest nose sphinx recommonmark sphinx-rtd-theme nevergrad + # add "auth_type = x509_proxy" to /opt/idds/etc/rucio.cfg # python setup.py install --old-and-unmanageable diff --git a/main/tools/env/setup_dev.sh b/main/tools/env/setup_dev.sh index 3e6bb3fa..d2294598 100644 --- a/main/tools/env/setup_dev.sh +++ b/main/tools/env/setup_dev.sh @@ -17,6 +17,8 @@ CondaDir=${RootDir}/../.conda/iDDS echo 'Root dir: ' $RootDir export IDDS_HOME=$RootDir +source /afs/cern.ch/user/w/wguan/workdisk/conda/setup.sh + conda activate $CondaDir #export PYTHONPATH=${IDDS_HOME}/lib:$PYTHONPATH From 71546ab5156c3d83c1b08acd8b9216dab841ceeb Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 8 Dec 2021 14:57:03 +0100 Subject: [PATCH 09/57] to support custom conditions --- docs/source/users/condition_examples.rst | 46 ++++++++++- main/lib/idds/tests/core_tests.py | 4 +- .../idds/tests/test_workflow_condition_v2.py | 77 +++++++++++++++++++ workflow/lib/idds/workflowv2/work.py | 57 ++++++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) diff --git a/docs/source/users/condition_examples.rst b/docs/source/users/condition_examples.rst index 3e67c742..9fc1bf46 100644 --- a/docs/source/users/condition_examples.rst +++ b/docs/source/users/condition_examples.rst @@ -6,7 +6,49 @@ iDDS provides composite conditions to support complicated workflows. conditions ~~~~~~~~~~~~~~~~~~~~~~~~ -1. condtions +1. work custom conditions + +Here are examples how to define custom conditions with work attributes + +.. code-block:: python + + from idds.workflow.work import Work + + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + work1.add_custom_condition('to_exit', True) # it's equal to: work1.add_custom_condition('to_exit', True, op='and') + assert(work1.get_custom_condition_status() is False) + work1.to_exit = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit = 'True' + assert(work1.get_custom_condition_status() is False) + work1.to_exit = True + assert(work1.get_custom_condition_status() is True) + + # or_custom_conditions or (and_custom_conditions) + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + # to_exit or (to_exit1 or to_exit2) + work1.add_custom_condition('to_exit', True, op='and') + work1.add_custom_condition('to_exit1', True, op='or') + work1.add_custom_condition('to_exit2', True, op='or') + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = 'False' + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = True + assert(work1.get_custom_condition_status() is True) + work1.to_exit1 = False + work1.to_exit2 = 'true' + assert(work1.get_custom_condition_status() is False) + work1.to_exit2 = True + assert(work1.get_custom_condition_status() is True) + work1.to_exit1 = False + work1.to_exit2 = False + work1.to_exit = True + assert(work1.get_custom_condition_status() is True) + + +2. condtions combination Here are conditions iDDS supports @@ -40,7 +82,7 @@ Here are conditions iDDS supports workflow.add_condition(conds) -2. condition trigger +3. condition trigger (This part is for iDDS developers. Users normally should not use this function.) Condition trigger is an option for iDDS to process whether to remember whether a condition work is already triggered. It's used to avoid duplicated triggering some processes(For example, when using work1.is_started to trigger work2. If the condition is not recorded, work2 can be triggered many times every time when the condition is evaluated). diff --git a/main/lib/idds/tests/core_tests.py b/main/lib/idds/tests/core_tests.py index cdea6309..f2609c1b 100644 --- a/main/lib/idds/tests/core_tests.py +++ b/main/lib/idds/tests/core_tests.py @@ -106,11 +106,11 @@ def show_works(req): for req in reqs: # print(req['request_id']) # print(rets) - # print(json_dumps(req, sort_keys=True, indent=4)) + print(json_dumps(req, sort_keys=True, indent=4)) # show_works(req) pass -# sys.exit(0) +sys.exit(0) """ # reqs = get_requests() diff --git a/main/lib/idds/tests/test_workflow_condition_v2.py b/main/lib/idds/tests/test_workflow_condition_v2.py index 3b28cac2..419ce8c6 100644 --- a/main/lib/idds/tests/test_workflow_condition_v2.py +++ b/main/lib/idds/tests/test_workflow_condition_v2.py @@ -29,6 +29,83 @@ class TestWorkflowCondtion(unittest.TestCase): + def test_work_custom_condition(self): + # init_p = Parameter({'input_dataset': 'data17:data17.test.raw.1'}) + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + work1.add_custom_condition('to_exit', True) + assert(work1.get_custom_condition_status() is False) + work1.to_exit = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit = 'False' + assert(work1.get_custom_condition_status() is False) + work1.to_exit = True + assert(work1.get_custom_condition_status() is True) + + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + work1.add_custom_condition('to_exit', 'true') + assert(work1.get_custom_condition_status() is False) + work1.to_exit = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit = 'False' + assert(work1.get_custom_condition_status() is False) + work1.to_exit = True + assert(work1.get_custom_condition_status() is False) + work1.to_exit = 'true' + assert(work1.get_custom_condition_status() is True) + + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + work1.add_custom_condition('to_exit1', True) + work1.add_custom_condition('to_exit2', True) + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = 'False' + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = True + assert(work1.get_custom_condition_status() is False) + work1.to_exit2 = 'true' + assert(work1.get_custom_condition_status() is False) + work1.to_exit2 = True + assert(work1.get_custom_condition_status() is True) + + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + work1.add_custom_condition('to_exit1', True, op='or') + work1.add_custom_condition('to_exit2', True, op='or') + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = 'False' + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = True + assert(work1.get_custom_condition_status() is True) + work1.to_exit1 = False + work1.to_exit2 = 'true' + assert(work1.get_custom_condition_status() is False) + work1.to_exit2 = True + assert(work1.get_custom_condition_status() is True) + + work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) + # to_exit or (to_exit1 or to_exit2) + work1.add_custom_condition('to_exit', True, op='and') + work1.add_custom_condition('to_exit1', True, op='or') + work1.add_custom_condition('to_exit2', True, op='or') + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = False + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = 'False' + assert(work1.get_custom_condition_status() is False) + work1.to_exit1 = True + assert(work1.get_custom_condition_status() is True) + work1.to_exit1 = False + work1.to_exit2 = 'true' + assert(work1.get_custom_condition_status() is False) + work1.to_exit2 = True + assert(work1.get_custom_condition_status() is True) + work1.to_exit1 = False + work1.to_exit2 = False + work1.to_exit = True + assert(work1.get_custom_condition_status() is True) + def test_condition(self): # init_p = Parameter({'input_dataset': 'data17:data17.test.raw.1'}) work1 = Work(executable='/bin/hostname', arguments=None, sandbox=None, work_id=1) diff --git a/workflow/lib/idds/workflowv2/work.py b/workflow/lib/idds/workflowv2/work.py index 6b04f250..6b57e545 100644 --- a/workflow/lib/idds/workflowv2/work.py +++ b/workflow/lib/idds/workflowv2/work.py @@ -544,6 +544,9 @@ def __init__(self, executable=None, arguments=None, parameters=None, setup=None, self.num_run = 0 + self.or_custom_conditions = {} + self.and_custom_conditions = {} + """ self._running_data_names = [] for name in ['internal_id', 'template_work_id', 'initialized', 'sequence_id', 'parameters', 'work_id', 'transforming', 'workdir', @@ -1041,6 +1044,60 @@ def get_global_parameter_from_output_data(self, key): def renew_parameters_from_attributes(self): pass + def add_custom_condition(self, key, value, op='and'): + # op in ['and', 'or'] + if op and op == 'or': + op = 'or' + else: + op = 'and' + if op == 'and': + self.and_custom_conditions[key] = value + else: + self.or_custom_conditions[key] = value + + def get_custom_condition_status_value_bool(self, key): + if hasattr(self, key) and getattr(self, key): + value = getattr(self, key) + if type(value) in [str]: + value = value.lower() + if value == 'true': + return True + else: + return False + elif type(value) in [bool]: + return value + elif type(value) in [int]: + if value > 0: + return True + else: + return False + else: + return value + else: + return False + + def get_custom_condition_status_value(self, key): + if hasattr(self, key) and getattr(self, key): + return getattr(self, key) + else: + return None + + def get_custom_condition_status(self): + if self.or_custom_conditions: + for key in self.or_custom_conditions: + value = self.get_custom_condition_status_value(key) + if value == self.or_custom_conditions[key]: + return True + + if self.and_custom_conditions: + for key in self.and_custom_conditions: + value = self.get_custom_condition_status_value(key) + if not (value == self.and_custom_conditions[key]): + return False + return True + + return False + def setup_logger(self): """ Setup logger From 52a1e4fc5c6c4d302681b24286be39d1d57145bb Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 8 Dec 2021 14:58:48 +0100 Subject: [PATCH 10/57] new version 0.9.2 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index 0ea0616c..47c1ef8f 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index 41746afd..6437e452 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.9.1 - - idds-workflow==0.9.1 \ No newline at end of file + - idds-common==0.9.2 + - idds-workflow==0.9.2 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index 0ea0616c..47c1ef8f 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index d161c850..8ace694a 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.9.1 - - idds-workflow==0.9.1 \ No newline at end of file + - idds-common==0.9.2 + - idds-workflow==0.9.2 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index 0ea0616c..47c1ef8f 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 881c1878..7eac9a6a 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index 55d1d1fd..d30c0dae 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.9.1 - - idds-workflow==0.9.1 \ No newline at end of file + - idds-common==0.9.2 + - idds-workflow==0.9.2 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index 0ea0616c..47c1ef8f 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 2be03b29..52d6cb8e 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -22,6 +22,6 @@ dependencies: - recommonmark # use Markdown with Sphinx - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - - idds-common==0.9.1 - - idds-workflow==0.9.1 - - idds-client==0.9.1 \ No newline at end of file + - idds-common==0.9.2 + - idds-workflow==0.9.2 + - idds-client==0.9.2 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index 0ea0616c..47c1ef8f 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/website/version.py b/website/version.py index 0ea0616c..47c1ef8f 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index 0ea0616c..47c1ef8f 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.1" +release_version = "0.9.2" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index 3834740c..567243c3 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.9.1 \ No newline at end of file + - idds-common==0.9.2 \ No newline at end of file From f31a4c11574e4e42b8a32603cb5bd3c223ccefc2 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 8 Dec 2021 15:11:18 +0100 Subject: [PATCH 11/57] to support custom conditions --- docs/source/users/condition_examples.rst | 1 + main/lib/idds/tests/test_workflow_condition_v2.py | 1 + workflow/lib/idds/workflowv2/work.py | 3 +++ 3 files changed, 5 insertions(+) diff --git a/docs/source/users/condition_examples.rst b/docs/source/users/condition_examples.rst index 9fc1bf46..c2be2b06 100644 --- a/docs/source/users/condition_examples.rst +++ b/docs/source/users/condition_examples.rst @@ -46,6 +46,7 @@ Here are examples how to define custom conditions with work attributes work1.to_exit2 = False work1.to_exit = True assert(work1.get_custom_condition_status() is True) + assert(work1.get_not_custom_condition_status() is False) 2. condtions combination diff --git a/main/lib/idds/tests/test_workflow_condition_v2.py b/main/lib/idds/tests/test_workflow_condition_v2.py index 419ce8c6..e799152e 100644 --- a/main/lib/idds/tests/test_workflow_condition_v2.py +++ b/main/lib/idds/tests/test_workflow_condition_v2.py @@ -105,6 +105,7 @@ def test_work_custom_condition(self): work1.to_exit2 = False work1.to_exit = True assert(work1.get_custom_condition_status() is True) + assert(work1.get_not_custom_condition_status() is False) def test_condition(self): # init_p = Parameter({'input_dataset': 'data17:data17.test.raw.1'}) diff --git a/workflow/lib/idds/workflowv2/work.py b/workflow/lib/idds/workflowv2/work.py index 6b57e545..ec8687b7 100644 --- a/workflow/lib/idds/workflowv2/work.py +++ b/workflow/lib/idds/workflowv2/work.py @@ -1098,6 +1098,9 @@ def get_custom_condition_status(self): return False + def get_not_custom_condition_status(self): + return not self.get_custom_condition_status() + def setup_logger(self): """ Setup logger From 4d955b43a6f8f97fed262da149f85609dc548b06 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 8 Dec 2021 15:12:23 +0100 Subject: [PATCH 12/57] new version 0.9.3 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index 47c1ef8f..a3e32f0b 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index 6437e452..d89d16b3 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.9.2 - - idds-workflow==0.9.2 \ No newline at end of file + - idds-common==0.9.3 + - idds-workflow==0.9.3 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index 47c1ef8f..a3e32f0b 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index 8ace694a..3649472c 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.9.2 - - idds-workflow==0.9.2 \ No newline at end of file + - idds-common==0.9.3 + - idds-workflow==0.9.3 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index 47c1ef8f..a3e32f0b 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 7eac9a6a..0719f48b 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index d30c0dae..5182a3d9 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.9.2 - - idds-workflow==0.9.2 \ No newline at end of file + - idds-common==0.9.3 + - idds-workflow==0.9.3 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index 47c1ef8f..a3e32f0b 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 52d6cb8e..2336bef6 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -22,6 +22,6 @@ dependencies: - recommonmark # use Markdown with Sphinx - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - - idds-common==0.9.2 - - idds-workflow==0.9.2 - - idds-client==0.9.2 \ No newline at end of file + - idds-common==0.9.3 + - idds-workflow==0.9.3 + - idds-client==0.9.3 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index 47c1ef8f..a3e32f0b 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/website/version.py b/website/version.py index 47c1ef8f..a3e32f0b 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index 47c1ef8f..a3e32f0b 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.2" +release_version = "0.9.3" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index 567243c3..b2a78431 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.9.2 \ No newline at end of file + - idds-common==0.9.3 \ No newline at end of file From 08aa17a715f5155fddfc3bfb33899acc95026d54 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 8 Dec 2021 22:51:36 +0100 Subject: [PATCH 13/57] fix custom condition with user attribute --- main/lib/idds/tests/core_tests.py | 8 ++++---- main/lib/idds/tests/test_migrate_requests.py | 4 ++-- workflow/lib/idds/workflowv2/work.py | 19 ++++++++++++++++++- workflow/lib/idds/workflowv2/workflow.py | 4 ++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/main/lib/idds/tests/core_tests.py b/main/lib/idds/tests/core_tests.py index f2609c1b..29acf991 100644 --- a/main/lib/idds/tests/core_tests.py +++ b/main/lib/idds/tests/core_tests.py @@ -102,15 +102,15 @@ def show_works(req): print(work_ids) -reqs = get_requests(request_id=237, with_detail=True, with_metadata=True) +reqs = get_requests(request_id=241, with_detail=True, with_metadata=True) for req in reqs: # print(req['request_id']) # print(rets) - print(json_dumps(req, sort_keys=True, indent=4)) + # print(json_dumps(req, sort_keys=True, indent=4)) # show_works(req) pass -sys.exit(0) +# sys.exit(0) """ # reqs = get_requests() @@ -126,7 +126,7 @@ def show_works(req): """ -tfs = get_transforms(request_id=238) +tfs = get_transforms(request_id=241) for tf in tfs: # print(tf) # print(tf['transform_metadata']['work'].to_dict()) diff --git a/main/lib/idds/tests/test_migrate_requests.py b/main/lib/idds/tests/test_migrate_requests.py index f16d3a27..fdccb480 100644 --- a/main/lib/idds/tests/test_migrate_requests.py +++ b/main/lib/idds/tests/test_migrate_requests.py @@ -31,7 +31,7 @@ def migrate(): cm1 = ClientManager(host=dev_host) # reqs = cm1.get_requests(request_id=290) - old_request_id = 237 + old_request_id = 241 # for old_request_id in [152]: # for old_request_id in [60]: # noqa E115 # for old_request_id in [200]: # noqa E115 @@ -41,7 +41,7 @@ def migrate(): cm2 = ClientManager(host=dev_host) # print(reqs) - for req in reqs: + for req in reqs[:1]: req = convert_old_req_2_workflow_req(req) workflow = req['request_metadata']['workflow'] workflow.clean_works() diff --git a/workflow/lib/idds/workflowv2/work.py b/workflow/lib/idds/workflowv2/work.py index ec8687b7..722039a6 100644 --- a/workflow/lib/idds/workflowv2/work.py +++ b/workflow/lib/idds/workflowv2/work.py @@ -1056,6 +1056,10 @@ def add_custom_condition(self, key, value, op='and'): self.or_custom_conditions[key] = value def get_custom_condition_status_value_bool(self, key): + user_key = "user_" + key + if hasattr(self, user_key): + key = user_key + if hasattr(self, key) and getattr(self, key): value = getattr(self, key) if type(value) in [str]: @@ -1077,12 +1081,16 @@ def get_custom_condition_status_value_bool(self, key): return False def get_custom_condition_status_value(self, key): + user_key = "user_" + key + if hasattr(self, user_key): + key = user_key + if hasattr(self, key) and getattr(self, key): return getattr(self, key) else: return None - def get_custom_condition_status(self): + def get_custom_condition_status_real(self): if self.or_custom_conditions: for key in self.or_custom_conditions: value = self.get_custom_condition_status_value(key) @@ -1098,6 +1106,15 @@ def get_custom_condition_status(self): return False + def get_custom_condition_status(self): + # self.logger.debug("get_custom_condition_status, or_custom_conditions: %s" % str(self.or_custom_conditions)) + # self.logger.debug("get_custom_condition_status, and_custom_conditions: %s" % str(self.and_custom_conditions)) + # self.logger.debug("get_custom_condition_status, work: %s" % (json_dumps(self, sort_keys=True, indent=4))) + + status = self.get_custom_condition_status_real() + self.logger.debug("get_custom_condition_status, status: %s" % (status)) + return status + def get_not_custom_condition_status(self): return not self.get_custom_condition_status() diff --git a/workflow/lib/idds/workflowv2/workflow.py b/workflow/lib/idds/workflowv2/workflow.py index 610b89f9..efecf8c6 100644 --- a/workflow/lib/idds/workflowv2/workflow.py +++ b/workflow/lib/idds/workflowv2/workflow.py @@ -1286,6 +1286,7 @@ def has_loop_condition(self): def get_loop_condition_status(self): if self.has_loop_condition(): self.loop_condition.load_conditions(self.works) + # self.logger.debug("Loop condition %s" % (json_dumps(self.loop_condition, sort_keys=True, indent=4))) return self.loop_condition.get_condition_status() return False @@ -2023,6 +2024,7 @@ def sync_works(self): if self.runs[str(self.num_run)].is_terminated(): if self.runs[str(self.num_run)].has_loop_condition(): if self.runs[str(self.num_run)].get_loop_condition_status(): + self.logger.info("num_run %s get_loop_condition_status %s, start next run" % (self.num_run, self.runs[str(self.num_run)].get_loop_condition_status())) self._num_run += 1 self.runs[str(self.num_run)] = self.template.copy() @@ -2031,6 +2033,8 @@ def sync_works(self): self.runs[str(self.num_run)].add_metadata_item('parameter_links', p_metadata) self.runs[str(self.num_run)].global_parameters = self.runs[str(self.num_run - 1)].global_parameters + else: + self.logger.info("num_run %s get_loop_condition_status %s, terminated loop" % (self.num_run, self.runs[str(self.num_run)].get_loop_condition_status())) def get_relation_map(self): if not self.runs: From 3d91203ea778aa0a5bd11e55908f1095ae5bef7e Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 10 Dec 2021 13:50:19 +0100 Subject: [PATCH 14/57] option not to use dataset name as workflow name --- client/lib/idds/client/clientmanager.py | 20 +- main/lib/idds/tests/core_tests.py | 6 +- .../idds/tests/test_domapanda_pandaclient.py | 189 ++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 main/lib/idds/tests/test_domapanda_pandaclient.py diff --git a/client/lib/idds/client/clientmanager.py b/client/lib/idds/client/clientmanager.py index 78717588..4d3c812d 100644 --- a/client/lib/idds/client/clientmanager.py +++ b/client/lib/idds/client/clientmanager.py @@ -38,7 +38,7 @@ def __init__(self, host=None): self.client = Client(host=self.host) @exception_handler - def submit(self, workflow, username=None, userdn=None): + def submit(self, workflow, username=None, userdn=None, use_dataset_name=True): """ Submit the workflow as a request to iDDS server. @@ -59,14 +59,16 @@ def submit(self, workflow, username=None, userdn=None): 'request_metadata': {'version': release_version, 'workload_id': workflow.get_workload_id(), 'workflow': workflow} } workflow.add_proxy() - primary_init_work = workflow.get_primary_initial_collection() - if primary_init_work: - if type(primary_init_work) in [Collection]: - props['scope'] = primary_init_work.scope - props['name'] = primary_init_work.name - else: - props['scope'] = primary_init_work['scope'] - props['name'] = primary_init_work['name'] + + if use_dataset_name: + primary_init_work = workflow.get_primary_initial_collection() + if primary_init_work: + if type(primary_init_work) in [Collection]: + props['scope'] = primary_init_work.scope + props['name'] = primary_init_work.name + else: + props['scope'] = primary_init_work['scope'] + props['name'] = primary_init_work['name'] # print(props) request_id = self.client.add_request(**props) diff --git a/main/lib/idds/tests/core_tests.py b/main/lib/idds/tests/core_tests.py index 29acf991..f489757f 100644 --- a/main/lib/idds/tests/core_tests.py +++ b/main/lib/idds/tests/core_tests.py @@ -102,15 +102,15 @@ def show_works(req): print(work_ids) -reqs = get_requests(request_id=241, with_detail=True, with_metadata=True) +reqs = get_requests(request_id=597, with_detail=True, with_metadata=True) for req in reqs: # print(req['request_id']) # print(rets) - # print(json_dumps(req, sort_keys=True, indent=4)) + print(json_dumps(req, sort_keys=True, indent=4)) # show_works(req) pass -# sys.exit(0) +sys.exit(0) """ # reqs = get_requests() diff --git a/main/lib/idds/tests/test_domapanda_pandaclient.py b/main/lib/idds/tests/test_domapanda_pandaclient.py new file mode 100644 index 00000000..8f0ddf29 --- /dev/null +++ b/main/lib/idds/tests/test_domapanda_pandaclient.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Sergey Padolski, , 2021 +# - Wen Guan, , 2021 + + +""" +Test client. +""" + +import string +import random + +# import traceback + +# from rucio.client.client import Client as Rucio_Client +# from rucio.common.exception import CannotAuthenticate + +# from idds.client.client import Client +# from idds.client.clientmanager import ClientManager +# from idds.common.constants import RequestType, RequestStatus +from idds.common.utils import get_rest_host +# from idds.tests.common import get_example_real_tape_stagein_request +# from idds.tests.common import get_example_prodsys2_tape_stagein_request + +# from idds.workflowv2.work import Work, Parameter, WorkStatus +# from idds.workflowv2.workflow import Condition, Workflow +from idds.workflowv2.workflow import Workflow +# from idds.atlas.workflowv2.atlasstageinwork import ATLASStageinWork +from idds.doma.workflowv2.domapandawork import DomaPanDAWork + +import idds.common.utils as idds_utils +import pandaclient.idds_api + + +task_queue = 'DOMA_LSST_GOOGLE_TEST' +task_queue = 'DOMA_LSST_GOOGLE_MERGE' + + +def randStr(chars=string.ascii_lowercase + string.digits, N=10): + return ''.join(random.choice(chars) for _ in range(N)) + + +class PanDATask(object): + name = None + step = None + dependencies = [] + + +def setup_workflow(): + + taskN1 = PanDATask() + taskN1.step = "step1" + taskN1.name = taskN1.step + "_" + randStr() + taskN1.dependencies = [ + {"name": "00000" + str(k), + "dependencies": [], + "submitted": False} for k in range(6) + ] + + taskN2 = PanDATask() + taskN2.step = "step2" + taskN2.name = taskN2.step + "_" + randStr() + taskN2.dependencies = [ + { + "name": "000010", + "dependencies": [{"task": taskN1.name, "inputname": "000001", "available": False}, + {"task": taskN1.name, "inputname": "000002", "available": False}], + "submitted": False + }, + { + "name": "000011", + "dependencies": [{"task": taskN1.name, "inputname": "000001", "available": False}, + {"task": taskN1.name, "inputname": "000002", "available": False}], + "submitted": False + }, + { + "name": "000012", + "dependencies": [{"task": taskN1.name, "inputname": "000001", "available": False}, + {"task": taskN1.name, "inputname": "000002", "available": False}], + "submitted": False + } + ] + + taskN3 = PanDATask() + taskN3.step = "step3" + taskN3.name = taskN3.step + "_" + randStr() + taskN3.dependencies = [ + { + "name": "000020", + "dependencies": [], + "submitted": False + }, + { + "name": "000021", + "dependencies": [{"task": taskN2.name, "inputname": "000010", "available": False}, + {"task": taskN2.name, "inputname": "000011", "available": False}], + "submitted": False + }, + { + "name": "000022", + "dependencies": [{"task": taskN2.name, "inputname": "000011", "available": False}, + {"task": taskN2.name, "inputname": "000012", "available": False}], + "submitted": False + }, + { + "name": "000023", + "dependencies": [], + "submitted": False + }, + { + "name": "000024", + "dependencies": [{"task": taskN3.name, "inputname": "000021", "available": False}, + {"task": taskN3.name, "inputname": "000023", "available": False}], + "submitted": False + }, + ] + + work1 = DomaPanDAWork(executable='echo', + primary_input_collection={'scope': 'pseudo_dataset', 'name': 'pseudo_input_collection#1'}, + output_collections=[{'scope': 'pseudo_dataset', 'name': 'pseudo_output_collection#1'}], + log_collections=[], dependency_map=taskN1.dependencies, + task_name=taskN1.name, task_queue=task_queue, + encode_command_line=True, + task_log={"dataset": "PandaJob_#{pandaid}/", + "destination": "local", + "param_type": "log", + "token": "local", + "type": "template", + "value": "log.tgz"}, + task_cloud='LSST') + work2 = DomaPanDAWork(executable='echo', + primary_input_collection={'scope': 'pseudo_dataset', 'name': 'pseudo_input_collection#2'}, + output_collections=[{'scope': 'pseudo_dataset', 'name': 'pseudo_output_collection#2'}], + log_collections=[], dependency_map=taskN2.dependencies, + task_name=taskN2.name, task_queue=task_queue, + task_log={"dataset": "PandaJob_#{pandaid}/", + "destination": "local", + "param_type": "log", + "token": "local", + "type": "template", + "value": "log.tgz"}, + encode_command_line=True, + task_cloud='LSST') + work3 = DomaPanDAWork(executable='echo', + primary_input_collection={'scope': 'pseudo_dataset', 'name': 'pseudo_input_collection#3'}, + output_collections=[{'scope': 'pseudo_dataset', 'name': 'pseudo_output_collection#3'}], + log_collections=[], dependency_map=taskN3.dependencies, + task_name=taskN3.name, task_queue=task_queue, + task_log={"dataset": "PandaJob_#{pandaid}/", + "destination": "local", + "param_type": "log", + "token": "local", + "type": "template", + "value": "log.tgz"}, + task_cloud='LSST', + encode_command_line=True) + + pending_time = 0.5 + # pending_time = None + workflow = Workflow(pending_time=pending_time) + workflow.add_work(work1) + workflow.add_work(work2) + workflow.add_work(work3) + return workflow + + +def submit(workflow, idds_server): + + c = pandaclient.idds_api.get_api(idds_utils.json_dumps, + idds_host=idds_server, compress=True, manager=True) + request_id = c.submit(workflow, username=None, use_dataset_name=False) + print("Submitted into iDDs with request id=%s", str(request_id)) + + +if __name__ == '__main__': + host = get_rest_host() + workflow = setup_workflow() + + # wm = ClientManager(host=host) + # request_id = wm.submit(workflow) + # print(request_id) + submit(workflow, host) From 931731a1009e6bea07c3f32934610149edb729cd Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 10 Dec 2021 13:51:03 +0100 Subject: [PATCH 15/57] new version 0.9.4 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index a3e32f0b..d024e236 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index d89d16b3..7fedbe17 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.9.3 - - idds-workflow==0.9.3 \ No newline at end of file + - idds-common==0.9.4 + - idds-workflow==0.9.4 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index a3e32f0b..d024e236 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index 3649472c..56ebb4b8 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.9.3 - - idds-workflow==0.9.3 \ No newline at end of file + - idds-common==0.9.4 + - idds-workflow==0.9.4 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index a3e32f0b..d024e236 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 0719f48b..516aa3e8 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index 5182a3d9..503efe9a 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.9.3 - - idds-workflow==0.9.3 \ No newline at end of file + - idds-common==0.9.4 + - idds-workflow==0.9.4 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index a3e32f0b..d024e236 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 2336bef6..41ab1899 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -22,6 +22,6 @@ dependencies: - recommonmark # use Markdown with Sphinx - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - - idds-common==0.9.3 - - idds-workflow==0.9.3 - - idds-client==0.9.3 \ No newline at end of file + - idds-common==0.9.4 + - idds-workflow==0.9.4 + - idds-client==0.9.4 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index a3e32f0b..d024e236 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/website/version.py b/website/version.py index a3e32f0b..d024e236 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index a3e32f0b..d024e236 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.3" +release_version = "0.9.4" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index b2a78431..2d86a323 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.9.3 \ No newline at end of file + - idds-common==0.9.4 \ No newline at end of file From 1d263f88ea96ba97a2c703c66823b9af19f7441e Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 10 Dec 2021 22:06:03 +0100 Subject: [PATCH 16/57] fix get_requests without detail --- main/lib/idds/rest/v1/requests.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/main/lib/idds/rest/v1/requests.py b/main/lib/idds/rest/v1/requests.py index 0fa80915..df475a6a 100644 --- a/main/lib/idds/rest/v1/requests.py +++ b/main/lib/idds/rest/v1/requests.py @@ -160,9 +160,15 @@ def get(self, request_id, workload_id, with_detail, with_metadata=False): with_metadata = True else: with_metadata = False + if with_detail: + with_request = False + else: + with_request = True # reqs = get_requests(request_id=request_id, workload_id=workload_id, to_json=True) - reqs = get_requests(request_id=request_id, workload_id=workload_id, with_detail=with_detail, with_metadata=with_metadata) + reqs = get_requests(request_id=request_id, workload_id=workload_id, + with_request=with_request, with_detail=with_detail, + with_metadata=with_metadata) except exceptions.NoObject as error: return self.generate_http_response(HTTP_STATUS_CODE.NotFound, exc_cls=error.__class__.__name__, exc_msg=error) except exceptions.IDDSException as error: From 181fb14df1ed513547d9b423e4a9e284673513a4 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 10 Dec 2021 22:06:36 +0100 Subject: [PATCH 17/57] fix workflow name --- workflow/lib/idds/workflow/workflow.py | 3 ++- workflow/lib/idds/workflowv2/workflow.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/workflow/lib/idds/workflow/workflow.py b/workflow/lib/idds/workflow/workflow.py index 1dbcf03f..e37fab10 100644 --- a/workflow/lib/idds/workflow/workflow.py +++ b/workflow/lib/idds/workflow/workflow.py @@ -533,7 +533,8 @@ def __init__(self, name=None, workload_id=None, lifetime=None, pending_time=None self.pending_time = pending_time if name: - self._name = name + "." + datetime.datetime.utcnow().strftime("%Y_%m_%d_%H_%M_%S_%f") + str(random.randint(1, 1000)) + # self._name = name + "." + datetime.datetime.utcnow().strftime("%Y_%m_%d_%H_%M_%S_%f") + str(random.randint(1, 1000)) + self._name = name else: self._name = 'idds.workflow.' + datetime.datetime.utcnow().strftime("%Y_%m_%d_%H_%M_%S_%f") + str(random.randint(1, 1000)) diff --git a/workflow/lib/idds/workflowv2/workflow.py b/workflow/lib/idds/workflowv2/workflow.py index efecf8c6..6a366697 100644 --- a/workflow/lib/idds/workflowv2/workflow.py +++ b/workflow/lib/idds/workflowv2/workflow.py @@ -591,7 +591,8 @@ def __init__(self, name=None, workload_id=None, lifetime=None, pending_time=None self.pending_time = pending_time if name: - self._name = name + "." + datetime.datetime.utcnow().strftime("%Y_%m_%d_%H_%M_%S_%f") + str(random.randint(1, 1000)) + # self._name = name + "." + datetime.datetime.utcnow().strftime("%Y_%m_%d_%H_%M_%S_%f") + str(random.randint(1, 1000)) + self._name = name else: self._name = 'idds.workflow.' + datetime.datetime.utcnow().strftime("%Y_%m_%d_%H_%M_%S_%f") + str(random.randint(1, 1000)) From 72da324bf5e0c17c6fd1eef515fbaf6b80765aa1 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 10 Dec 2021 22:07:12 +0100 Subject: [PATCH 18/57] update tests --- main/lib/idds/tests/core_tests.py | 3 ++- main/lib/idds/tests/test_domapanda.py | 4 +++- main/lib/idds/tests/test_domapanda_pandaclient.py | 2 ++ main/lib/idds/tests/test_migrate_requests.py | 1 + monitor/conf.js | 12 ++++++------ 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/main/lib/idds/tests/core_tests.py b/main/lib/idds/tests/core_tests.py index f489757f..3ebadd45 100644 --- a/main/lib/idds/tests/core_tests.py +++ b/main/lib/idds/tests/core_tests.py @@ -102,7 +102,8 @@ def show_works(req): print(work_ids) -reqs = get_requests(request_id=597, with_detail=True, with_metadata=True) +# reqs = get_requests(request_id=599, with_detail=True, with_metadata=True) +reqs = get_requests(request_id=599, with_request=True, with_detail=False, with_metadata=True) for req in reqs: # print(req['request_id']) # print(rets) diff --git a/main/lib/idds/tests/test_domapanda.py b/main/lib/idds/tests/test_domapanda.py index e15b6bbc..03023338 100644 --- a/main/lib/idds/tests/test_domapanda.py +++ b/main/lib/idds/tests/test_domapanda.py @@ -16,6 +16,7 @@ import string import random +import time # import traceback @@ -163,6 +164,7 @@ def setup_workflow(): workflow.add_work(work1) workflow.add_work(work2) workflow.add_work(work3) + workflow.name = 'test_workflow.idds.%s.test' % time.time() return workflow @@ -171,5 +173,5 @@ def setup_workflow(): workflow = setup_workflow() wm = ClientManager(host=host) - request_id = wm.submit(workflow) + request_id = wm.submit(workflow, use_dataset_name=False) print(request_id) diff --git a/main/lib/idds/tests/test_domapanda_pandaclient.py b/main/lib/idds/tests/test_domapanda_pandaclient.py index 8f0ddf29..b006ac6e 100644 --- a/main/lib/idds/tests/test_domapanda_pandaclient.py +++ b/main/lib/idds/tests/test_domapanda_pandaclient.py @@ -16,6 +16,7 @@ import string import random +import time # import traceback @@ -168,6 +169,7 @@ def setup_workflow(): workflow.add_work(work1) workflow.add_work(work2) workflow.add_work(work3) + workflow.name = 'test_workflow.idds.%s' % time.time() return workflow diff --git a/main/lib/idds/tests/test_migrate_requests.py b/main/lib/idds/tests/test_migrate_requests.py index fdccb480..0f0181df 100644 --- a/main/lib/idds/tests/test_migrate_requests.py +++ b/main/lib/idds/tests/test_migrate_requests.py @@ -41,6 +41,7 @@ def migrate(): cm2 = ClientManager(host=dev_host) # print(reqs) + print("num requests: %s" % len(reqs)) for req in reqs[:1]: req = convert_old_req_2_workflow_req(req) workflow = req['request_metadata']['workflow'] diff --git a/monitor/conf.js b/monitor/conf.js index bdcda0bf..3cb7ca81 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus768.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus768.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus768.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus768.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus768.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus768.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus745.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus745.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus745.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus745.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus745.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus745.cern.ch:443/idds/monitor/null/null/false/false/true" } From 41a92960c9a0a2f570f06feda323272a219c9eb0 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 10 Dec 2021 22:07:57 +0100 Subject: [PATCH 19/57] new version 0.9.5 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index d024e236..1306461e 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index 7fedbe17..b972abb5 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.9.4 - - idds-workflow==0.9.4 \ No newline at end of file + - idds-common==0.9.5 + - idds-workflow==0.9.5 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index d024e236..1306461e 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index 56ebb4b8..287fa075 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.9.4 - - idds-workflow==0.9.4 \ No newline at end of file + - idds-common==0.9.5 + - idds-workflow==0.9.5 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index d024e236..1306461e 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 516aa3e8..3af3b875 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index 503efe9a..0bda9433 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.9.4 - - idds-workflow==0.9.4 \ No newline at end of file + - idds-common==0.9.5 + - idds-workflow==0.9.5 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index d024e236..1306461e 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 41ab1899..d3de449c 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -22,6 +22,6 @@ dependencies: - recommonmark # use Markdown with Sphinx - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - - idds-common==0.9.4 - - idds-workflow==0.9.4 - - idds-client==0.9.4 \ No newline at end of file + - idds-common==0.9.5 + - idds-workflow==0.9.5 + - idds-client==0.9.5 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index d024e236..1306461e 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/website/version.py b/website/version.py index d024e236..1306461e 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index d024e236..1306461e 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.4" +release_version = "0.9.5" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index 2d86a323..2df9a55c 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.9.4 \ No newline at end of file + - idds-common==0.9.5 \ No newline at end of file From c05b01327db8349c0d1f1d0eac83fd2aa48da7b5 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 15 Dec 2021 17:06:32 +0100 Subject: [PATCH 20/57] example to explain workflow relation map --- main/lib/idds/tests/core_tests.py | 4 ++ main/lib/idds/tests/relation_map_test.py | 64 ++++++++++++++++++++++++ monitor/conf.js | 12 ++--- workflow/lib/idds/workflowv2/utils.py | 31 ++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 main/lib/idds/tests/relation_map_test.py create mode 100644 workflow/lib/idds/workflowv2/utils.py diff --git a/main/lib/idds/tests/core_tests.py b/main/lib/idds/tests/core_tests.py index 3ebadd45..c29f9158 100644 --- a/main/lib/idds/tests/core_tests.py +++ b/main/lib/idds/tests/core_tests.py @@ -110,6 +110,10 @@ def show_works(req): print(json_dumps(req, sort_keys=True, indent=4)) # show_works(req) pass + workflow = req['request_metadata']['workflow'] + if hasattr(workflow, 'get_relation_map'): + # print(json_dumps(workflow.get_relation_map(), sort_keys=True, indent=4)) + pass sys.exit(0) diff --git a/main/lib/idds/tests/relation_map_test.py b/main/lib/idds/tests/relation_map_test.py new file mode 100644 index 00000000..07c74726 --- /dev/null +++ b/main/lib/idds/tests/relation_map_test.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 + + +from idds.workflowv2.utils import show_relation_map + + +relation_map = [ + { # noqa E126 + "work": { + "external_id": None, + "workload_id": 8492 + } + }, + { + "work": { + "external_id": None, + "workload_id": 8493 + } + }, + { + "work": { + "external_id": None, + "workload_id": 8491 + } + }, + { + "work": { + "external_id": None, + "workload_id": 8496 + } + }, + { + "work": { + "external_id": None, + "workload_id": 8494 + } + }, + { + "next_works": [ + { + "work": { + "external_id": None, + "workload_id": 8497 + } + } + ], + "work": { + "external_id": None, + "workload_id": 8495 + } + } + ] # noqa E126 + + +if __name__ == "__main__": + show_relation_map(relation_map) diff --git a/monitor/conf.js b/monitor/conf.js index 3cb7ca81..5fefd1c2 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus745.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus745.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus745.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus745.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus745.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus745.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus784.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus784.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus784.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus784.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus784.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus784.cern.ch:443/idds/monitor/null/null/false/false/true" } diff --git a/workflow/lib/idds/workflowv2/utils.py b/workflow/lib/idds/workflowv2/utils.py new file mode 100644 index 00000000..dcc033dd --- /dev/null +++ b/workflow/lib/idds/workflowv2/utils.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 + + +def show_relation_map(relation_map, level=0): + # a workflow with a list of works. + if level == 0: + prefix = "" + else: + prefix = " " * level * 4 + + for item in relation_map: + if type(item) in [dict]: + # it's a Work + print("%s%s" % (prefix, item['work']['workload_id'])) + if 'next_works' in item: + # print("%s%s next_works:" % (prefix, item['work']['workload_id'])) + next_works = item['next_works'] + # it's a list. + show_relation_map(next_works, level=level + 1) + elif type(item) in [list]: + # it's a subworkflow with a list of works. + print("%ssubworkflow:" % (prefix)) + show_relation_map(next_works, level=level + 1) From 639cc865efd76b7f0e24597a982e6302545d65a1 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 17 Dec 2021 16:26:56 +0100 Subject: [PATCH 21/57] fix poll panda tasks --- doma/lib/idds/doma/workflow/domapandawork.py | 2 +- doma/lib/idds/doma/workflowv2/domapandawork.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/doma/lib/idds/doma/workflow/domapandawork.py b/doma/lib/idds/doma/workflow/domapandawork.py index 31ecea06..7f4afb75 100644 --- a/doma/lib/idds/doma/workflow/domapandawork.py +++ b/doma/lib/idds/doma/workflow/domapandawork.py @@ -871,7 +871,7 @@ def poll_panda_task(self, processing=None, input_output_maps=None): self.logger.debug("poll_panda_task, task_info[0]: %s" % str(task_info[0])) if task_info[0] != 0: self.logger.warn("poll_panda_task %s, error getting task status, task_info: %s" % (task_id, str(task_info))) - return ProcessingStatus.Submitting, {} + return ProcessingStatus.Submitting, [] task_info = task_info[1] diff --git a/doma/lib/idds/doma/workflowv2/domapandawork.py b/doma/lib/idds/doma/workflowv2/domapandawork.py index 8966b83b..84e9dcfd 100644 --- a/doma/lib/idds/doma/workflowv2/domapandawork.py +++ b/doma/lib/idds/doma/workflowv2/domapandawork.py @@ -165,6 +165,17 @@ def depend_on(self, work): return True return False + def get_ancestry_works(self): + tasks = set([]) + for job in self.dependency_map: + inputs_dependency = job["dependencies"] + + for input_d in inputs_dependency: + task_name = input_d['task'] + if task_name not in tasks: + tasks.add(task_name) + return list(tasks) + def poll_external_collection(self, coll): try: if coll.status in [CollectionStatus.Closed]: @@ -876,7 +887,7 @@ def poll_panda_task(self, processing=None, input_output_maps=None): self.logger.debug("poll_panda_task, task_info[0]: %s" % str(task_info[0])) if task_info[0] != 0: self.logger.warn("poll_panda_task %s, error getting task status, task_info: %s" % (task_id, str(task_info))) - return ProcessingStatus.Submitting, {} + return ProcessingStatus.Submitting, [] task_info = task_info[1] From 9120122028d8bdeaa1697fc19a3d72c8bfb89ee2 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 17 Dec 2021 16:27:23 +0100 Subject: [PATCH 22/57] improve task relation map based on dependency map --- workflow/lib/idds/workflowv2/work.py | 3 ++ workflow/lib/idds/workflowv2/workflow.py | 60 +++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/workflow/lib/idds/workflowv2/work.py b/workflow/lib/idds/workflowv2/work.py index 722039a6..31e36047 100644 --- a/workflow/lib/idds/workflowv2/work.py +++ b/workflow/lib/idds/workflowv2/work.py @@ -1261,6 +1261,9 @@ def set_arguments(self, arguments): def get_arguments(self): return self.arguments + def get_ancestry_works(self): + return [] + def has_to_release_inputs(self): if self.backup_to_release_inputs['0'] or self.backup_to_release_inputs['1'] or self.backup_to_release_inputs['2']: return True diff --git a/workflow/lib/idds/workflowv2/workflow.py b/workflow/lib/idds/workflowv2/workflow.py index 6a366697..98a1516c 100644 --- a/workflow/lib/idds/workflowv2/workflow.py +++ b/workflow/lib/idds/workflowv2/workflow.py @@ -1537,7 +1537,11 @@ def resume_works(self): def get_relation_data(self, work): ret = {'work': {'workload_id': work.workload_id, - 'external_id': work.external_id}} + 'external_id': work.external_id, + 'work_name': work.get_work_name()}} + if hasattr(work, 'get_ancestry_works'): + ret['work']['ancestry_works'] = work.get_ancestry_works() + next_works = work.next_works if next_works: next_works_data = [] @@ -1551,12 +1555,64 @@ def get_relation_data(self, work): ret['next_works'] = next_works_data return ret + def organzie_based_on_ancestry_works(self, works): + new_ret = [] + + ordered_items = {} + left_items = [] + for item in works: + if type(item) in [dict]: + if 'ancestry_works' not in item['work'] or not item['work']['ancestry_works']: + new_ret.append(item) + ordered_items[item['work']['work_name']] = item + else: + # ancestry_works = item['work']['ancestry_works'] + left_items.append(item) + elif type(item) in [list]: + # subworkflow + # work_names, ancestry_works = self.get_workflow_ancestry_works(item) + # if not ancestry_works: + # new_ret.append(item) + # currently now support to use dependency_map to depend_on a workflow. + # depending on a workflow should use Condition. It's already processed. + new_ret.append(item) + while True: + new_left_items = left_items + left_items = [] + has_updates = False + for item in new_left_items: + ancestry_works = item['work']['ancestry_works'] + all_ancestry_ready = True + for work_name in ancestry_works: + if work_name not in ordered_items and work_name != item['work']['work_name']: + all_ancestry_ready = False + if all_ancestry_ready: + for work_name in ancestry_works: + if work_name != item['work']['work_name']: + if 'next_works' not in ordered_items[work_name]: + ordered_items[work_name]['next_works'] = [item] + else: + ordered_items[work_name]['next_works'].append(item) + has_updates = True + ordered_items[item['work']['work_name']] = item + else: + left_items.append(item) + if not has_updates or not left_items: + break + for item in left_items: + new_ret.append(item) + return new_ret + def get_relation_map(self): ret = [] init_works = self.init_works for internal_id in init_works: - work_data = self.get_relation_data(self.works[internal_id]) + if isinstance(self.works[internal_id], Workflow): + work_data = self.works[internal_id].get_relation_map() + else: + work_data = self.get_relation_data(self.works[internal_id]) ret.append(work_data) + ret = self.organzie_based_on_ancestry_works(ret) return ret def clean_works(self): From 0ee781b3f0f44e77b8b753f097cc1b29b4e519df Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 21 Jan 2022 13:32:24 +0100 Subject: [PATCH 23/57] auto create database based on sqlalchemy models --- common/lib/idds/common/config.py | 13 ++-- main/etc/panda/panda.cfg | 13 +++- main/lib/idds/orm/base/models.py | 35 +++++---- main/lib/idds/orm/base/utils.py | 107 +++++++++++++++----------- main/lib/idds/tests/test_domapanda.py | 2 + main/setup.py | 2 +- main/tools/env/create_database.py | 21 +++++ main/tools/env/create_postgres_db.sh | 16 ++++ main/tools/env/destroy_database.py | 28 +++++++ main/tools/env/environment.yml | 3 +- main/tools/env/install_idds_full.sh | 5 ++ main/tools/env/setup_panda.sh | 10 +++ 12 files changed, 183 insertions(+), 72 deletions(-) create mode 100644 main/tools/env/create_database.py create mode 100644 main/tools/env/create_postgres_db.sh create mode 100644 main/tools/env/destroy_database.py create mode 100644 main/tools/env/setup_panda.sh diff --git a/common/lib/idds/common/config.py b/common/lib/idds/common/config.py index 721aa234..746797f9 100644 --- a/common/lib/idds/common/config.py +++ b/common/lib/idds/common/config.py @@ -126,9 +126,10 @@ def config_get_bool(section, option): break if not __HAS_CONFIG: - raise Exception("Could not load configuration file." - "IDDS looks for a configuration file, in order:" - "\n\t${IDDS_CONFIG}" - "\n\t${IDDS_HOME}/etc/idds/idds.cfg" - "\n\t/etc/idds/idds.cfg" - "\n\t${VIRTUAL_ENV}/etc/idds/idds.cfg") + pass + # raise Exception("Could not load configuration file." + # "IDDS looks for a configuration file, in order:" + # "\n\t${IDDS_CONFIG}" + # "\n\t${IDDS_HOME}/etc/idds/idds.cfg" + # "\n\t/etc/idds/idds.cfg" + # "\n\t${VIRTUAL_ENV}/etc/idds/idds.cfg") diff --git a/main/etc/panda/panda.cfg b/main/etc/panda/panda.cfg index 470e9aad..e6b1e29e 100644 --- a/main/etc/panda/panda.cfg +++ b/main/etc/panda/panda.cfg @@ -1,4 +1,11 @@ [panda] -panda_monitor_url = https://panda-doma.cern.ch -panda_url = http://ai-idds-01.cern.ch:25080/server/panda -panda_url_ssl = https://ai-idds-01.cern.ch:25443/server/panda +# panda_monitor_url = https://panda-doma.cern.ch +# panda_url = http://ai-idds-01.cern.ch:25080/server/panda +# panda_url_ssl = https://ai-idds-01.cern.ch:25443/server/panda + +PANDA_AUTH = oidc +PANDA_URL_SSL = https://pandaserver-doma.cern.ch:25443/server/panda +PANDA_URL = http://pandaserver-doma.cern.ch:25080/server/panda +PANDAMON_URL = https://panda-doma.cern.ch +PANDA_AUTH_VO = panda_dev + diff --git a/main/lib/idds/orm/base/models.py b/main/lib/idds/orm/base/models.py index 657af50c..8394bdac 100644 --- a/main/lib/idds/orm/base/models.py +++ b/main/lib/idds/orm/base/models.py @@ -33,7 +33,7 @@ MessageSource, MessageDestination) from idds.common.utils import date_to_str from idds.orm.base.enum import EnumSymbol -from idds.orm.base.types import JSON, JSONString, EnumWithValue +from idds.orm.base.types import JSON, EnumWithValue from idds.orm.base.session import BASE, DEFAULT_SCHEMA_NAME from idds.common.constants import (SCOPE_LENGTH, NAME_LENGTH) @@ -148,7 +148,7 @@ class Request(BASE, ModelBase): next_poll_at = Column("next_poll_at", DateTime, default=datetime.datetime.utcnow) accessed_at = Column("accessed_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) expired_at = Column("expired_at", DateTime) - errors = Column(JSON()) + errors = Column(String(1024)) _request_metadata = Column('request_metadata', JSON()) _processing_metadata = Column('processing_metadata', JSON()) @@ -209,8 +209,8 @@ def update(self, values, flush=True, session=None): _table_args = (PrimaryKeyConstraint('request_id', name='REQUESTS_PK'), CheckConstraint('status IS NOT NULL', name='REQUESTS_STATUS_ID_NN'), # UniqueConstraint('name', 'scope', 'requester', 'request_type', 'transform_tag', 'workload_id', name='REQUESTS_NAME_SCOPE_UQ '), - Index('REQUESTS_SCOPE_NAME_IDX', 'workload_id', 'request_id', 'name', 'scope'), - Index('REQUESTS_STATUS_PRIO_IDX', 'status', 'priority', 'workload_id', 'request_id', 'locking', 'updated_at', 'next_poll_at', 'created_at')) + Index('REQUESTS_SCOPE_NAME_IDX', 'name', 'scope', 'workload_id'), + Index('REQUESTS_STATUS_PRIO_IDX', 'status', 'priority', 'request_id', 'locking', 'updated_at', 'next_poll_at', 'created_at')) class Workprogress(BASE, ModelBase): @@ -234,7 +234,7 @@ class Workprogress(BASE, ModelBase): next_poll_at = Column("next_poll_at", DateTime, default=datetime.datetime.utcnow) accessed_at = Column("accessed_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) expired_at = Column("expired_at", DateTime) - errors = Column(JSON()) + errors = Column(String(1024)) workprogress_metadata = Column(JSON()) processing_metadata = Column(JSON()) @@ -242,7 +242,7 @@ class Workprogress(BASE, ModelBase): ForeignKeyConstraint(['request_id'], ['requests.request_id'], name='REQ2WORKPROGRESS_REQ_ID_FK'), CheckConstraint('status IS NOT NULL', name='WORKPROGRESS_STATUS_ID_NN'), # UniqueConstraint('name', 'scope', 'requester', 'request_type', 'transform_tag', 'workload_id', name='REQUESTS_NAME_SCOPE_UQ '), - Index('WORKPROGRESS_SCOPE_NAME_IDX', 'workprogress_id', 'request_id', 'name', 'scope'), + Index('WORKPROGRESS_SCOPE_NAME_IDX', 'name', 'scope', 'workprogress_id'), Index('WORKPROGRESS_STATUS_PRIO_IDX', 'status', 'priority', 'workprogress_id', 'locking', 'updated_at', 'next_poll_at', 'created_at')) @@ -325,7 +325,7 @@ def update(self, values, flush=True, session=None): _table_args = (PrimaryKeyConstraint('transform_id', name='TRANSFORMS_PK'), CheckConstraint('status IS NOT NULL', name='TRANSFORMS_STATUS_ID_NN'), Index('TRANSFORMS_TYPE_TAG_IDX', 'transform_type', 'transform_tag', 'transform_id'), - Index('TRANSFORMS_STATUS_UPDATED_IDX', 'status', 'locking', 'updated_at', 'next_poll_at', 'created_at')) + Index('TRANSFORMS_STATUS_UPDATED_AT_IDX', 'status', 'locking', 'updated_at', 'next_poll_at', 'created_at')) class Workprogress2transform(BASE, ModelBase): @@ -470,14 +470,13 @@ class Content(BASE, ModelBase): coll_id = Column(BigInteger().with_variant(Integer, "sqlite")) request_id = Column(BigInteger().with_variant(Integer, "sqlite")) workload_id = Column(Integer()) - transform_id = Column(BigInteger().with_variant(Integer, "sqlite")) map_id = Column(BigInteger().with_variant(Integer, "sqlite"), default=0) scope = Column(String(SCOPE_LENGTH)) name = Column(String(NAME_LENGTH)) - min_id = Column(Integer()) - max_id = Column(Integer()) + min_id = Column(Integer(), default=0) + max_id = Column(Integer(), default=0) content_type = Column(EnumWithValue(ContentType)) - content_relation_type = Column(EnumWithValue(ContentRelationType)) + content_relation_type = Column(EnumWithValue(ContentRelationType), default=0) status = Column(EnumWithValue(ContentStatus)) substatus = Column(EnumWithValue(ContentStatus)) locking = Column(EnumWithValue(ContentLocking)) @@ -492,13 +491,13 @@ class Content(BASE, ModelBase): updated_at = Column("updated_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) accessed_at = Column("accessed_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) expired_at = Column("expired_at", DateTime) - content_metadata = Column(JSONString()) + content_metadata = Column(String(100)) _table_args = (PrimaryKeyConstraint('content_id', name='CONTENTS_PK'), # UniqueConstraint('name', 'scope', 'coll_id', 'content_type', 'min_id', 'max_id', name='CONTENT_SCOPE_NAME_UQ'), # UniqueConstraint('name', 'scope', 'coll_id', 'min_id', 'max_id', name='CONTENT_SCOPE_NAME_UQ'), # UniqueConstraint('content_id', 'coll_id', name='CONTENTS_UQ'), - UniqueConstraint('transform_id', 'coll_id', 'map_id', 'name', name='CONTENT_ID_UQ'), + UniqueConstraint('transform_id', 'coll_id', 'map_id', 'name', 'min_id', 'max_id', name='CONTENT_ID_UQ'), ForeignKeyConstraint(['transform_id'], ['transforms.transform_id'], name='CONTENTS_TRANSFORM_ID_FK'), ForeignKeyConstraint(['coll_id'], ['collections.coll_id'], name='CONTENTS_COLL_ID_FK'), CheckConstraint('status IS NOT NULL', name='CONTENTS_STATUS_ID_NN'), @@ -548,7 +547,9 @@ class Message(BASE, ModelBase): msg_content = Column(JSON()) _table_args = (PrimaryKeyConstraint('msg_id', name='MESSAGES_PK'), - Index('MESSAGES_TYPE_ST_IDX', 'msg_type', 'status', 'destination', 'request_id')) + Index('MESSAGES_TYPE_ST_IDX', 'msg_type', 'status', 'destination', 'request_id'), + Index('MESSAGES_TYPE_ST_TF_IDX', 'msg_type', 'status', 'destination', 'transform_id'), + Index('MESSAGES_TYPE_ST_PR_IDX', 'msg_type', 'status', 'destination', 'processing_id')) def register_models(engine): @@ -556,7 +557,8 @@ def register_models(engine): Creates database tables for all models with the given engine """ - models = (Request, Workprogress, Transform, Workprogress2transform, Processing, Collection, Content, Health, Message) + # models = (Request, Workprogress, Transform, Workprogress2transform, Processing, Collection, Content, Health, Message) + models = (Request, Transform, Processing, Collection, Content, Health, Message) for model in models: model.metadata.create_all(engine) # pylint: disable=maybe-no-member @@ -567,7 +569,8 @@ def unregister_models(engine): Drops database tables for all models with the given engine """ - models = (Request, Workprogress, Transform, Workprogress2transform, Processing, Collection, Content, Health, Message) + # models = (Request, Workprogress, Transform, Workprogress2transform, Processing, Collection, Content, Health, Message) + models = (Request, Transform, Processing, Collection, Content, Health, Message) for model in models: model.metadata.drop_all(engine) # pylint: disable=maybe-no-member diff --git a/main/lib/idds/orm/base/utils.py b/main/lib/idds/orm/base/utils.py index 279bde08..6391e4f7 100644 --- a/main/lib/idds/orm/base/utils.py +++ b/main/lib/idds/orm/base/utils.py @@ -14,72 +14,89 @@ """ import traceback +from typing import Union -from sqlalchemy.engine import reflection -from sqlalchemy.schema import DropTable, DropConstraint, ForeignKeyConstraint, MetaData, Table +import sqlalchemy +# from sqlalchemy.engine import reflection +from sqlalchemy.engine import Inspector +from sqlalchemy import inspect +from sqlalchemy.dialects.postgresql.base import PGInspector +from sqlalchemy.schema import CreateSchema, MetaData, Table, DropTable, ForeignKeyConstraint, DropConstraint +from sqlalchemy.sql.ddl import DropSchema +from idds.common.config import config_has_option, config_get from idds.orm.base import session, models def build_database(echo=True, tests=False): """Build the database. """ engine = session.get_engine(echo=echo) + + if config_has_option('database', 'schema'): + schema = config_get('database', 'schema') + if schema: + print('Schema set in config, trying to create schema:', schema) + try: + engine.execute(CreateSchema(schema)) + except Exception as e: + print('Cannot create schema, please validate manually if schema creation is needed, continuing:', e) + print(traceback.format_exc()) + models.register_models(engine) def destroy_database(echo=True): """Destroy the database""" engine = session.get_engine(echo=echo) - models.unregister_models(engine) + try: + models.unregister_models(engine) + except Exception as e: + print('Cannot destroy schema -- assuming already gone, continuing:', e) + print(traceback.format_exc()) -def destory_everything(echo=True): + +def destroy_everything(echo=True): """ Using metadata.reflect() to get all constraints and tables. metadata.drop_all() as it handles cyclical constraints between tables. Ref. http://www.sqlalchemy.org/trac/wiki/UsageRecipes/DropEverything """ engine = session.get_engine(echo=echo) - conn = engine.connect() - - # the transaction only applies if the DB supports - # transactional DDL, i.e. Postgresql, MS SQL Server - trans = conn.begin() - - inspector = reflection.Inspector.from_engine(engine) - - # gather all data first before dropping anything. - # some DBs lock after things have been dropped in - # a transaction. - metadata = MetaData() - - tbs = [] - all_fks = [] - - for table_name in inspector.get_table_names(): - fks = [] - for fk in inspector.get_foreign_keys(table_name): - if not fk['name']: - continue - fks.append(ForeignKeyConstraint((), (), name=fk['name'])) - t = Table(table_name, metadata, *fks) - tbs.append(t) - all_fks.extend(fks) - - for fkc in all_fks: - try: - print(str(DropConstraint(fkc)) + ';') - conn.execute(DropConstraint(fkc)) - except: # noqa: B901 - print(traceback.format_exc()) - - for table in tbs: - try: - print(str(DropTable(table)).strip() + ';') - conn.execute(DropTable(table)) - except: # noqa: B901 - print(traceback.format_exc()) - - trans.commit() + + try: + # the transaction only applies if the DB supports + # transactional DDL, i.e. Postgresql, MS SQL Server + with engine.begin() as conn: + + inspector = inspect(conn) # type: Union[Inspector, PGInspector] + + for tname, fkcs in reversed( + inspector.get_sorted_table_and_fkc_names(schema='*')): + if tname: + drop_table_stmt = DropTable(Table(tname, MetaData(), schema='*')) + conn.execute(drop_table_stmt) + elif fkcs: + if not engine.dialect.supports_alter: + continue + for tname, fkc in fkcs: + fk_constraint = ForeignKeyConstraint((), (), name=fkc) + Table(tname, MetaData(), fk_constraint) + drop_constraint_stmt = DropConstraint(fk_constraint) + conn.execute(drop_constraint_stmt) + + if config_has_option('database', 'schema'): + schema = config_get('database', 'schema') + if schema: + conn.execute(DropSchema(schema, cascade=True)) + + if engine.dialect.name == 'postgresql': + assert isinstance(inspector, PGInspector), 'expected a PGInspector' + for enum in inspector.get_enums(schema='*'): + sqlalchemy.Enum(**enum).drop(bind=conn) + + except Exception as e: + print('Cannot destroy db:', e) + print(traceback.format_exc()) def dump_schema(): diff --git a/main/lib/idds/tests/test_domapanda.py b/main/lib/idds/tests/test_domapanda.py index 03023338..7f7e3de8 100644 --- a/main/lib/idds/tests/test_domapanda.py +++ b/main/lib/idds/tests/test_domapanda.py @@ -39,6 +39,8 @@ task_queue = 'DOMA_LSST_GOOGLE_TEST' task_queue = 'DOMA_LSST_GOOGLE_MERGE' +task_queue = 'SLAC_TEST' +task_queue = 'DOMA_LSST_SLAC_TEST' def randStr(chars=string.ascii_lowercase + string.digits, N=10): diff --git a/main/setup.py b/main/setup.py index 560378b2..89806393 100644 --- a/main/setup.py +++ b/main/setup.py @@ -118,7 +118,7 @@ def replace_data_path(wsgi_file, install_data_path): # config and cron files ('etc/idds/', glob.glob('etc/idds/*.template')), ('etc/idds/rest', glob.glob('etc/idds/rest/*template')), - ('tools/env/', glob.glob('tools/env/*.yml')), + ('tools/env/', glob.glob('tools/env/*')), ] scripts = glob.glob('bin/*') diff --git a/main/tools/env/create_database.py b/main/tools/env/create_database.py new file mode 100644 index 00000000..a83c9b11 --- /dev/null +++ b/main/tools/env/create_database.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 - 2022 + +import sys +import os.path +base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(base_path) +os.chdir(base_path) + +from idds.orm.base.utils import build_database # noqa E402 + + +if __name__ == '__main__': + build_database() diff --git a/main/tools/env/create_postgres_db.sh b/main/tools/env/create_postgres_db.sh new file mode 100644 index 00000000..4eb10a85 --- /dev/null +++ b/main/tools/env/create_postgres_db.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +su - postgres + +psql postgres +\password +#postpass +postgres=# create database idds; +CREATE DATABASE +postgres=# create user iddsuser with encrypted password 'iddspass'; +CREATE ROLE +postgres=# grant all privileges on database idds to iddsuser; +GRANT +postgres=# + +psql --host=localhost --port=5432 --username=iddsuser --password --dbname=idds diff --git a/main/tools/env/destroy_database.py b/main/tools/env/destroy_database.py new file mode 100644 index 00000000..02a13e3e --- /dev/null +++ b/main/tools/env/destroy_database.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 - 2022 + +import argparse +import sys +import os.path +base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(base_path) +os.chdir(base_path) + +from idds.orm.base.utils import destroy_database, destroy_everything # noqa E402 + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("--destroy-everything", action="store_true", default=False, help='Destroy all tables+constraints') + args = parser.parse_args() + if args.destroy_everything: + destroy_everything() + else: + destroy_database() diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index d3de449c..c59be825 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -22,6 +22,7 @@ dependencies: - recommonmark # use Markdown with Sphinx - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization + - psycopg2-binary - idds-common==0.9.5 - idds-workflow==0.9.5 - - idds-client==0.9.5 \ No newline at end of file + - idds-client==0.9.5 diff --git a/main/tools/env/install_idds_full.sh b/main/tools/env/install_idds_full.sh index 3ef23f23..6ad21928 100644 --- a/main/tools/env/install_idds_full.sh +++ b/main/tools/env/install_idds_full.sh @@ -28,6 +28,7 @@ pip install rucio-clients-atlas rucio-clients panda-client # root ca.crt to /opt/idds/etc/ca.crt pip install requests SQLAlchemy urllib3 retrying mod_wsgi flask futures stomp.py cx-Oracle unittest2 pep8 flake8 pytest nose sphinx recommonmark sphinx-rtd-theme nevergrad + pip install psycopg2-binary # add "auth_type = x509_proxy" to /opt/idds/etc/rucio.cfg @@ -89,3 +90,7 @@ sphinx-apidoc -f -o ./source/codes/client/ ../client/lib/idds sphinx-apidoc -f -o ./source/codes/workflow/ ../workflow/lib/idds sphinx-apidoc -f -o ./source/codes/atlas/ ../atlas/lib/idds sphinx-apidoc -f -o ./source/codes/doma/ ../doma/lib/idds + + +yum install fetch-crl.noarch +yum install lcg-CA diff --git a/main/tools/env/setup_panda.sh b/main/tools/env/setup_panda.sh new file mode 100644 index 00000000..943a3827 --- /dev/null +++ b/main/tools/env/setup_panda.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +export PANDA_AUTH=oidc +export PANDA_URL_SSL=https://pandaserver-doma.cern.ch:25443/server/panda +export PANDA_URL=http://pandaserver-doma.cern.ch:25080/server/panda +export PANDAMON_URL=https://panda-doma.cern.ch +export PANDA_AUTH_VO=panda_dev + +export PANDA_CONFIG_ROOT=/afs/cern.ch/user/w/wguan/workdisk/iDDS/main/etc/panda/ + From 237b85bd60e887f170316204ed90ef94864cda77 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 28 Jan 2022 21:06:22 +0100 Subject: [PATCH 24/57] add setup --- client/bin/idds | 73 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/client/bin/idds b/client/bin/idds index c05d3fce..3d37216d 100755 --- a/client/bin/idds +++ b/client/bin/idds @@ -6,7 +6,7 @@ # http://www.apache.org/licenses/LICENSE-2.0OA # # Authors: -# - Wen Guan, , 2020 +# - Wen Guan, , 2020 - 2021 """ iDDS CLI @@ -26,6 +26,41 @@ from idds.client.version import release_version from idds.client.clientmanager import ClientManager +def setup(args): + cm = ClientManager(host=args.host) + cm.setup_local_configuration(local_config_root=args.local_config_root, + config=args.config, host=args.host, + auth_type=args.auth_type, + x509_proxy=args.x509_proxy) + return cm + + +def setup_oidc_token(args): + cm = ClientManager(host=args.host) + cm.setup_local_configuration(local_config_root=args.local_config_root, + config=args.config, host=args.host, + oidc_refresh_lifetime=args.oidc_refresh_lifetime, + oidc_issuer=args.oidc_issuer, + oidc_audience=args.oidc_audience, + oidc_token=args.oidc_token, + oidc_auto=args.oidc_auto, + oidc_username=args.oidc_username, + oidc_password=args.oidc_password, + oidc_scope=args.oidc_scope, + oidc_polling=args.oidc_polling) + cm.setup_oidc_token() + + +def clean_oidc_token(args): + cm = ClientManager(host=args.host) + cm.clean_oidc_token() + + +def check_oidc_token_status(args): + cm = ClientManager(host=args.host) + cm.check_oidc_token_status() + + def get_requests_status(args): wm = ClientManager(host=args.host) ret = wm.get_status(request_id=args.request_id, workload_id=args.workload_id, with_detail=args.with_detail) @@ -103,9 +138,40 @@ def get_parser(): # common items oparser.add_argument('--version', action='version', version='%(prog)s ' + release_version) - oparser.add_argument('--config', dest="config", help="The iDDS configuration file to use.") + oparser.add_argument('--local_config_root', dest="local_config_root", default=None, help="The root path of local configurations. Default is ~/.idds/.") + oparser.add_argument('--config', dest=None, help="The iDDS configuration file to use. Default is ~/.idds/idds.cfg.") oparser.add_argument('--verbose', '-v', default=False, action='store_true', help="Print more verbose output.") - oparser.add_argument('-H', '--host', dest="host", metavar="ADDRESS", help="The iDDS Rest host. For example: https://iddsserver.cern.ch:443/idds") + oparser.add_argument('-H', '--host', dest="host", metavar="ADDRESS", help="The iDDS Rest host. For example: https://hostname:443/idds") + + # setup + setup_parser = subparsers.add_parser('setup', help='Setup local configuration') + setup_parser.set_defaults(function=setup) + setup_parser.add_argument('-H', '--host', dest="host", metavar="ADDRESS", help="The iDDS Rest host. For example: https://hostname:443/idds") + setup_parser.add_argument('--auth_type', dest='auth_type', action='store', default=None, help='The auth_type in [x509_proxy, oidc, saml]. Default is x509_proxy.') + setup_parser.add_argument('--x509_proxy', dest='x509_proxy', action='store', default=None, help='The x509 proxy path. Default is /tmp/x509up_u%d.' % os.geteuid()) + + # setup token + token_setup_parser = subparsers.add_parser('setup_oidc_token', help='Setup authentication token') + token_setup_parser.set_defaults(function=setup_oidc_token) + token_setup_parser.add_argument('--oidc_refresh_lifetime', dest='oidc_refresh_lifetime', default=None, help='The oidc refresh lifetime') + token_setup_parser.add_argument('--oidc_issuer', dest='oidc_issuer', default=None, help='The oidc issuer') + token_setup_parser.add_argument('--oidc_audience', dest='oidc_audience', default=None, help='The oidc audience') + token_setup_parser.add_argument('--oidc_token', dest='oidc_token', default=None, help='The oidc token path. Default is {local_config_root}/.oidc_token.') + token_setup_parser.add_argument('--oidc_auto', dest='oidc_auto', default=False, action='store_true', help='Get oidc token automatically, requiring oidc_username and oidc_password') + token_setup_parser.add_argument('--oidc_username', dest='oidc_username', default=None, help='The oidc username for getting oidc token, with --oidc_auto') + token_setup_parser.add_argument('--oidc_password', dest='oidc_password', default=None, help='The oidc password for getting oidc token, with --oidc_auto') + token_setup_parser.add_argument('--oidc_scope', dest='oidc_scope', default=None, help='The oidc scope. Default is openid profile.') + token_setup_parser.add_argument('--oidc_polling', dest='oidc_polling', default=False, help='whether polling oidc') + # token_setup_parser.add_argument('--saml_username', dest='saml_username', default=None, help='The SAML username') + # token_setup_parser.add_argument('--saml_password', dest='saml_password', default=None, help='The saml password') + + # clean token + token_clean_parser = subparsers.add_parser('clean_oidc_token', help='Clean authentication token') + token_clean_parser.set_defaults(function=clean_oidc_token) + + # check token status + token_check_parser = subparsers.add_parser('oidc_token_info', help='Check authentication token information') + token_check_parser.set_defaults(function=check_oidc_token_status) # get request status req_status_parser = subparsers.add_parser('get_requests_status', help='Get the requests status') @@ -209,6 +275,7 @@ if __name__ == '__main__': if args.verbose: logging.setLevel(logging.DEBUG) start_time = time.time() + result = args.function(args) end_time = time.time() if args.verbose: From ce67bc74162d7ea48e0d170e71f38625f8713436 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 28 Jan 2022 21:07:47 +0100 Subject: [PATCH 25/57] add setup panda token --- doma/bin/setup_panda_token | 21 +++ doma/bin/setup_panda_token.py | 265 ++++++++++++++++++++++++++++++++++ main/bin/run-idds | 5 +- 3 files changed, 289 insertions(+), 2 deletions(-) create mode 100755 doma/bin/setup_panda_token create mode 100755 doma/bin/setup_panda_token.py diff --git a/doma/bin/setup_panda_token b/doma/bin/setup_panda_token new file mode 100755 index 00000000..ff45700c --- /dev/null +++ b/doma/bin/setup_panda_token @@ -0,0 +1,21 @@ +#!/bin/bash +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2022 + +export PANDA_AUTH=oidc +export PANDA_URL_SSL=https://pandaserver-doma.cern.ch:25443/server/panda +export PANDA_URL=http://pandaserver-doma.cern.ch:25080/server/panda +export PANDAMON_URL=https://panda-doma.cern.ch +export PANDA_AUTH_VO=panda_dev + +export PANDA_CONFIG_ROOT=~/.panda/ + +CurrentDir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +python ${CurrentDir}/setup_panda_token.py $@ diff --git a/doma/bin/setup_panda_token.py b/doma/bin/setup_panda_token.py new file mode 100755 index 00000000..a7c5ce3f --- /dev/null +++ b/doma/bin/setup_panda_token.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 - 2022 + +""" +Setup Panda IAM token +""" + +from __future__ import print_function + +import argparse +import argcomplete +import base64 +import datetime +import json +import os +import socket +import subprocess +import sys + +try: + from urllib import urlencode + from urllib2 import urlopen, Request, HTTPError +except ImportError: + from urllib.request import urlopen, Request + from urllib.parse import urlencode + from urllib.error import HTTPError + +from pandaclient import panda_api +from pandaclient import Client +# from pandaclient import openidc_utils +from pandaclient import PLogger + + +def run_command(cmd): + """ + Runs a command in an out-of-procees shell. + """ + process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=os.setsid) + stdout, stderr = process.communicate() + if stdout is not None and type(stdout) in [bytes]: + stdout = stdout.decode() + if stderr is not None and type(stderr) in [bytes]: + stderr = stderr.decode() + status = process.returncode + return status, stdout, stderr + + +def setup_panda_token(verbose=False): + c = panda_api.get_api() + c.hello(verbose) + + +def get_expire_time(verbose=False): + try: + # token_file = openidc_utils.OpenIdConnect_Utils().get_token_path() + curl = Client._Curl() + curl.verbose = verbose + tmp_log = PLogger.getPandaLogger() + oidc = curl.get_oidc(tmp_log) + token_file = oidc.get_token_path() + if os.path.exists(token_file): + with open(token_file) as f: + data = json.load(f) + enc = data['id_token'].split('.')[1] + enc += '=' * (-len(enc) % 4) + dec = json.loads(base64.urlsafe_b64decode(enc.encode())) + exp_time = datetime.datetime.utcfromtimestamp(dec['exp']) + delta = exp_time - datetime.datetime.utcnow() + minutes = delta.total_seconds() / 60 + print('Token will expire in %s minutes.' % minutes) + print('Token expiration time : {0} UTC'.format(exp_time.strftime("%Y-%m-%d %H:%M:%S"))) + else: + print("Cannot find token file.") + except Exception as e: + print('failed to decode cached token with {0}'.format(e)) + + +def get_token_info(verbose=False): + # c = panda_api.get_api() + curl = Client._Curl() + curl.verbose = verbose + token_info = curl.get_token_info() + # print(token_info) + if token_info and type(token_info) in [dict]: + for key in token_info: + print("%s: %s" % (key, token_info[key])) + get_expire_time() + else: + print(token_info) + + +def get_refresh_token_string(verbose=False): + try: + curl = Client._Curl() + curl.verbose = verbose + tmp_log = PLogger.getPandaLogger() + oidc = curl.get_oidc(tmp_log) + token_file = oidc.get_token_path() + if os.path.exists(token_file): + with open(token_file) as f: + data = json.load(f) + enc = data['id_token'].split('.')[1] + enc += '=' * (-len(enc) % 4) + dec = json.loads(base64.urlsafe_b64decode(enc.encode())) + exp_time = datetime.datetime.utcfromtimestamp(dec['exp']) + delta = exp_time - datetime.datetime.utcnow() + minutes = delta.total_seconds() / 60 + print('Token will expire in %s minutes.' % minutes) + print('Token expiration time : {0} UTC'.format(exp_time.strftime("%Y-%m-%d %H:%M:%S"))) + if delta < datetime.timedelta(minutes=0): + print("Token already expired. Cannot refresh.") + return False, None, None + return True, data['refresh_token'], delta + else: + print("Cannot find token file.") + except Exception as e: + print('failed to decode cached token with {0}'.format(e)) + return False, None, None + + +def oidc_refresh_token(oidc, token_endpoint, client_id, client_secret, refresh_token_string): + if oidc.verbose: + oidc.log_stream.debug('refreshing token') + data = {'client_id': client_id, + 'client_secret': client_secret, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token_string} + rdata = urlencode(data).encode() + req = Request(token_endpoint, rdata) + req.add_header('content-type', 'application/x-www-form-urlencoded') + try: + conn = urlopen(req) + text = conn.read() + if oidc.verbose: + oidc.log_stream.debug(text) + id_token = json.loads(text)['id_token'] + text = json.dumps(json.loads(text)) + with open(oidc.get_token_path(), 'w') as f: + f.write(text) + return True, id_token + except HTTPError as e: + return False, 'code={0}. reason={1}. description={2}'.format(e.code, e.reason, e.read()) + except Exception as e: + return False, str(e) + + +def refresh_token(minutes=30, verbose=False): + curl = Client._Curl() + curl.verbose = verbose + tmp_log = PLogger.getPandaLogger() + oidc = curl.get_oidc(tmp_log) + + status, refresh_token, delta = get_refresh_token_string() + if not status: + print("Cannot refresh token.") + return False + + print("Fetching auth configuration from: %s" % str(oidc.auth_config_url)) + s, o = oidc.fetch_page(oidc.auth_config_url) + if not s: + print("Failed to get Auth configuration: " + o) + return False + auth_config = o + + print("Fetching endpoint configuration from: %s" % str(auth_config['oidc_config_url'])) + s, o = oidc.fetch_page(auth_config['oidc_config_url']) + if not s: + print("Failed to get endpoint configuration: " + o) + return False + endpoint_config = o + + # s, o = oidc.refresh_token(endpoint_config['token_endpoint'], auth_config['client_id'], + # auth_config['client_secret'], refresh_token) + s, o = oidc_refresh_token(oidc, endpoint_config['token_endpoint'], auth_config['client_id'], + auth_config['client_secret'], refresh_token) + if not s: + print("Failed to refresh token: " + o) + if delta < datetime.timedelta(minutes=minutes): + print("The left lifetime of the token is less than required %s minutes" % minutes) + return False + else: + return True + else: + print("Success to refresh token: " + o) + if delta < datetime.timedelta(minutes=minutes): + print("The left lifetime of the token is less than required %s minutes" % minutes) + return False + else: + return True + return True + + +def refresh_token_action(min_minutes, email, stop_idds, verbose=False): + status = refresh_token(min_minutes, verbose=verbose) + if not status: + if email: + hostname = socket.getfqdn() + cmd = """sendmail "%s"< Date: Fri, 28 Jan 2022 21:09:38 +0100 Subject: [PATCH 26/57] fix JSONString --- main/lib/idds/orm/base/models.py | 8 ++++---- main/lib/idds/orm/base/types.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/main/lib/idds/orm/base/models.py b/main/lib/idds/orm/base/models.py index 8394bdac..7eb60519 100644 --- a/main/lib/idds/orm/base/models.py +++ b/main/lib/idds/orm/base/models.py @@ -33,7 +33,7 @@ MessageSource, MessageDestination) from idds.common.utils import date_to_str from idds.orm.base.enum import EnumSymbol -from idds.orm.base.types import JSON, EnumWithValue +from idds.orm.base.types import JSON, JSONString, EnumWithValue from idds.orm.base.session import BASE, DEFAULT_SCHEMA_NAME from idds.common.constants import (SCOPE_LENGTH, NAME_LENGTH) @@ -148,7 +148,7 @@ class Request(BASE, ModelBase): next_poll_at = Column("next_poll_at", DateTime, default=datetime.datetime.utcnow) accessed_at = Column("accessed_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) expired_at = Column("expired_at", DateTime) - errors = Column(String(1024)) + errors = Column(JSONString(1024)) _request_metadata = Column('request_metadata', JSON()) _processing_metadata = Column('processing_metadata', JSON()) @@ -234,7 +234,7 @@ class Workprogress(BASE, ModelBase): next_poll_at = Column("next_poll_at", DateTime, default=datetime.datetime.utcnow) accessed_at = Column("accessed_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) expired_at = Column("expired_at", DateTime) - errors = Column(String(1024)) + errors = Column(JSONString(1024)) workprogress_metadata = Column(JSON()) processing_metadata = Column(JSON()) @@ -491,7 +491,7 @@ class Content(BASE, ModelBase): updated_at = Column("updated_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) accessed_at = Column("accessed_at", DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) expired_at = Column("expired_at", DateTime) - content_metadata = Column(String(100)) + content_metadata = Column(JSONString(100)) _table_args = (PrimaryKeyConstraint('content_id', name='CONTENTS_PK'), # UniqueConstraint('name', 'scope', 'coll_id', 'content_type', 'min_id', 'max_id', name='CONTENT_SCOPE_NAME_UQ'), diff --git a/main/lib/idds/orm/base/types.py b/main/lib/idds/orm/base/types.py index 881e6410..55dd3afb 100644 --- a/main/lib/idds/orm/base/types.py +++ b/main/lib/idds/orm/base/types.py @@ -126,16 +126,20 @@ class JSONString(TypeDecorator): impl = types.JSON + def __init__(self, length=1024, *args, **kwargs): + super(JSONString, self).__init__(*args, **kwargs) + self._length = length + def load_dialect_impl(self, dialect): if dialect.name == 'postgresql': - return dialect.type_descriptor(JSONB()) + return dialect.type_descriptor(String(self._length)) elif dialect.name == 'mysql': - return dialect.type_descriptor(String(255)) + return dialect.type_descriptor(String(self._length)) # return dialect.type_descriptor(types.JSON()) elif dialect.name == 'oracle': - return dialect.type_descriptor(String(255)) + return dialect.type_descriptor(String(self._length)) else: - return dialect.type_descriptor(String(255)) + return dialect.type_descriptor(String(self._length)) def process_bind_param(self, value, dialect): if value is None: From 673930b22f729cf730e03866d8b3a24c95625964 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 28 Jan 2022 21:10:43 +0100 Subject: [PATCH 27/57] improve client --- client/lib/idds/client/client.py | 5 +- client/lib/idds/client/clientmanager.py | 149 +++++++++++++++++++++++- common/lib/idds/common/config.py | 67 +++++++++-- 3 files changed, 208 insertions(+), 13 deletions(-) diff --git a/client/lib/idds/client/client.py b/client/lib/idds/client/client.py index 9f4acef4..67542aa1 100644 --- a/client/lib/idds/client/client.py +++ b/client/lib/idds/client/client.py @@ -34,7 +34,7 @@ class Client(RequestClient, CatalogClient, CacherClient, HPOClient, LogsClient, """Main client class for IDDS rest callings.""" - def __init__(self, host=None, timeout=600): + def __init__(self, host=None, timeout=600, client_proxy=None): """ Constructor for the IDDS main client class. @@ -42,7 +42,8 @@ def __init__(self, host=None, timeout=600): :param timeout: the timeout of the request (in seconds). """ - client_proxy = self.get_user_proxy() + # if client_proxy is None: + # client_proxy = self.get_user_proxy() super(Client, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) def get_user_proxy(sellf): diff --git a/client/lib/idds/client/clientmanager.py b/client/lib/idds/client/clientmanager.py index 4d3c812d..57853157 100644 --- a/client/lib/idds/client/clientmanager.py +++ b/client/lib/idds/client/clientmanager.py @@ -12,15 +12,23 @@ """ Workflow manager. """ +import os import logging import tabulate -from idds.common.utils import setup_logging +try: + import ConfigParser +except ImportError: + import configparser as ConfigParser + +from idds.common.utils import setup_logging, get_proxy_path from idds.client.version import release_version from idds.client.client import Client +from idds.common.config import get_local_cfg_file, get_local_config_root, get_local_config_value from idds.common.constants import RequestType, RequestStatus -from idds.common.utils import get_rest_host, exception_handler +# from idds.common.utils import get_rest_host, exception_handler +from idds.common.utils import exception_handler # from idds.workflowv2.work import Work, Parameter, WorkStatus # from idds.workflowv2.workflow import Condition, Workflow @@ -33,9 +41,142 @@ class ClientManager: def __init__(self, host=None): self.host = host + # if self.host is None: + # self.host = get_rest_host() + # self.client = Client(host=self.host) + + self.local_config_root = None, + self.config = None, + self.auth_type = None, + self.x509_proxy = None, + + self.oidc_refresh_lifetime = None, + self.oidc_issuer = None, + self.oidc_audience = None, + self.oidc_token = None, + self.oidc_auto = None, + self.oidc_username = None, + self.oidc_password = None, + self.oidc_scope = None, + self.oidc_polling = None + + self.configuration = ConfigParser.SafeConfigParser() + self.setup_local_configuration(host=host) if self.host is None: - self.host = get_rest_host() - self.client = Client(host=self.host) + local_cfg = self.get_local_cfg_file() + self.host = self.get_config_value(local_cfg, 'rest', 'host', current=self.host, default=None) + + self.client = Client(host=self.host, client_proxy=self.x509_proxy) + + def get_local_config_root(self): + local_cfg_root = get_local_config_root(self.local_config_root) + return local_cfg_root + + def get_local_cfg_file(self): + local_cfg = get_local_cfg_file(self.local_config_root) + return local_cfg + + def get_config_value(self, configuration, section, name, current, default): + if type(configuration) in [str]: + config = ConfigParser.SafeConfigParser() + config.read(configuration) + configuration = config + value = get_local_config_value(configuration, section, name, current, default) + return value + + @exception_handler + def get_local_configuration(self): + local_cfg = self.get_local_cfg_file() + config = ConfigParser.SafeConfigParser() + if os.path.exists(local_cfg): + config.read(local_cfg) + + self.config = self.get_config_value(config, section='common', name='config', current=self.config, + default=os.path.join(self.get_local_config_root(), 'idds.cfg')) + self.auth_type = self.get_config_value(config, 'common', 'auth_type', current=self.auth_type, default='x509_proxy') + + self.host = self.get_config_value(config, 'rest', 'host', current=self.host, default=None) + + self.x509_proxy = self.get_config_value(config, 'x509', 'x509_proxy', current=self.x509_proxy, + default='/tmp/x509up_u%d' % os.geteuid()) + if not self.x509_proxy or not os.path.exists(self.x509_proxy): + proxy = get_proxy_path() + if proxy: + self.x509_proxy = proxy + + self.oidc_refresh_lifetime = self.get_config_value(config, 'oidc', 'oidc_refresh_lifetime', + current=self.oidc_refresh_lifetime, default=None) + self.oidc_issuer = self.get_config_value(config, 'oidc', 'oidc_issuer', current=self.oidc_audience, default=None) + self.oidc_audience = self.get_config_value(config, 'oidc', 'oidc_audience', current=self.oidc_audience, default=None) + self.oidc_token = self.get_config_value(config, 'oidc', 'oidc_token', current=self.oidc_token, + default=os.path.join(self.get_local_config_root(), '.oidc_token')) + self.oidc_auto = self.get_config_value(config, 'oidc', 'oidc_auto', current=self.oidc_auto, default=False) + self.oidc_username = self.get_config_value(config, 'oidc', 'oidc_username', current=self.oidc_username, default=None) + self.oidc_password = self.get_config_value(config, 'oidc', 'oidc_password', current=self.oidc_password, default=None) + self.oidc_scope = self.get_config_value(config, 'oidc', 'oidc_scope', current=self.oidc_scope, default='openid profile') + self.oidc_polling = self.get_config_value(config, 'oidc', 'oidc_polling', current=self.oidc_polling, default=False) + + self.configuration = config + + @exception_handler + def save_local_configuration(self): + local_cfg = self.get_local_cfg_file() + with open(local_cfg, 'w') as configfile: + self.configuration.write(configfile) + + @exception_handler + def setup_local_configuration(self, local_config_root=None, config=None, host=None, + auth_type=None, x509_proxy=None, + oidc_refresh_lifetime=None, oidc_issuer=None, + oidc_audience=None, oidc_token=None, + oidc_auto=None, oidc_username=None, oidc_password=None, + oidc_scope=None, oidc_polling=None): + + if 'IDDS_CONFIG' in os.environ and os.environ['IDDS_CONFIG']: + if config is None: + print("IDDS_CONFIG is set. Will use it.") + config = os.environ['IDDS_CONFIG'] + else: + print("config is set to %s. Ignore IDDS_CONFIG" % config) + + self.local_config_root = local_config_root + self.config = config + self.host = host + self.auth_type = auth_type + self.x509_proxy = x509_proxy + self.oidc_refresh_lifetime = oidc_refresh_lifetime + self.oidc_issuer = oidc_issuer + self.oidc_audience = oidc_audience + self.oidc_token = oidc_token + self.oidc_auto = oidc_auto + self.oidc_username = oidc_username + self.oidc_password = oidc_password + self.oidc_scope = oidc_scope + self.oidc_polling = oidc_polling + + self.get_local_configuration() + self.save_local_configuration() + + @exception_handler + def setup_oidc_token(self): + """" + Setup oidc token + """ + pass + + @exception_handler + def clean_oidc_token(self): + """" + Clean oidc token + """ + pass + + @exception_handler + def check_oidc_token_status(self): + """" + Check oidc token status + """ + pass @exception_handler def submit(self, workflow, username=None, userdn=None, use_dataset_name=True): diff --git a/common/lib/idds/common/config.py b/common/lib/idds/common/config.py index 746797f9..77192e9a 100644 --- a/common/lib/idds/common/config.py +++ b/common/lib/idds/common/config.py @@ -105,6 +105,54 @@ def config_get_bool(section, option): return __CONFIG.getboolean(section, option) +def get_local_config_root(local_config_root=None): + if 'IDDS_LOCAL_CONFIG_ROOT' in os.environ and os.environ['IDDS_LOCAL_CONFIG_ROOT']: + if local_config_root is None: + print("IDDS_LOCAL_CONFIG_ROOT is set. Will use it.") + local_config_root = os.environ['IDDS_LOCAL_CONFIG_ROOT'] + else: + print("local_config_root is set to %s. Ignore IDDS_LOCAL_CONFIG_ROOT" % local_config_root) + + if local_config_root is None: + # local_config_root = "~/.idds" + local_config_root = os.path.join(os.path.expanduser("~"), ".idds") + + if not os.path.exists(local_config_root): + os.makedirs(local_config_root) + return local_config_root + + +def get_local_cfg_file(local_config_root=None): + local_config_root = get_local_config_root(local_config_root) + local_cfg = os.path.join(local_config_root, 'idds_local.cfg') + return local_cfg + + +def get_local_config_value(configuration, section, name, current, default): + value = None + if configuration.has_section(section) and configuration.has_option(section, name): + if name in ['oidc_refresh_lifetime']: + value = configuration.getint(section, name) + elif name in ['oidc_auto', 'oidc_polling']: + value = configuration.getboolean(section, name) + else: + value = configuration.get(section, name) + if current is not None: + value = current + elif value is None: + value = default + + if not configuration.has_section(section): + configuration.add_section(section) + if value is not None: + if name in ['oidc_refresh_lifetime']: + value = str(value) + elif name in ['oidc_auto', 'oidc_polling']: + value = str(value).lower() + configuration.set(section, name, value) + return value + + __CONFIG = ConfigParser.SafeConfigParser() __HAS_CONFIG = False @@ -126,10 +174,15 @@ def config_get_bool(section, option): break if not __HAS_CONFIG: - pass - # raise Exception("Could not load configuration file." - # "IDDS looks for a configuration file, in order:" - # "\n\t${IDDS_CONFIG}" - # "\n\t${IDDS_HOME}/etc/idds/idds.cfg" - # "\n\t/etc/idds/idds.cfg" - # "\n\t${VIRTUAL_ENV}/etc/idds/idds.cfg") + local_cfg = get_local_cfg_file() + if os.path.exists(local_cfg): + __CONFIG.read(local_cfg) + __HAS_CONFIG = True + else: + raise Exception("Could not load configuration file." + "For iDDS client, please run 'idds setup' to create local config file." + "For an iDDS server, IDDS looks for a configuration file, in order:" + "\n\t${IDDS_CONFIG}" + "\n\t${IDDS_HOME}/etc/idds/idds.cfg" + "\n\t/etc/idds/idds.cfg" + "\n\t${VIRTUAL_ENV}/etc/idds/idds.cfg") From a2998faf36fe0390d5426ab89da7a698e23557bd Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 28 Jan 2022 21:11:38 +0100 Subject: [PATCH 28/57] add support to use oidc for submitting panda jobs --- doma/lib/idds/doma/workflow/domapandawork.py | 18 ++++++++++++++++ .../lib/idds/doma/workflowv2/domapandawork.py | 21 +++++++++++++++++++ main/etc/panda/panda.cfg | 13 ++++++------ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/doma/lib/idds/doma/workflow/domapandawork.py b/doma/lib/idds/doma/workflow/domapandawork.py index 7f4afb75..fe90f030 100644 --- a/doma/lib/idds/doma/workflow/domapandawork.py +++ b/doma/lib/idds/doma/workflow/domapandawork.py @@ -61,6 +61,9 @@ def __init__(self, executable=None, arguments=None, parameters=None, setup=None, self.panda_url = None self.panda_url_ssl = None self.panda_monitor = None + self.panda_auth = None + self.panda_auth_vo = None + self.panda_config_root = None self.dependency_map = dependency_map self.dependency_map_deleted = [] @@ -130,6 +133,15 @@ def load_panda_urls(self): self.panda_url_ssl = panda_config.get('panda', 'panda_url_ssl') os.environ['PANDA_URL_SSL'] = self.panda_url_ssl # self.logger.debug("Panda url ssl: %s" % str(self.panda_url_ssl)) + if panda_config.has_option('panda', 'panda_auth'): + self.panda_auth = panda_config.get('panda', 'panda_auth') + os.environ['PANDA_AUTH'] = self.panda_auth + if panda_config.has_option('panda', 'panda_auth_vo'): + self.panda_auth_vo = panda_config.get('panda', 'panda_auth_vo') + os.environ['PANDA_AUTH_VO'] = self.panda_auth_vo + if panda_config.has_option('panda', 'panda_config_root'): + self.panda_config_root = panda_config.get('panda', 'panda_config_root') + os.environ['PANDA_CONFIG_ROOT'] = self.panda_config_root if not self.panda_monitor and 'PANDA_MONITOR_URL' in os.environ and os.environ['PANDA_MONITOR_URL']: self.panda_monitor = os.environ['PANDA_MONITOR_URL'] @@ -140,6 +152,12 @@ def load_panda_urls(self): if not self.panda_url_ssl and 'PANDA_URL_SSL' in os.environ and os.environ['PANDA_URL_SSL']: self.panda_url_ssl = os.environ['PANDA_URL_SSL'] # self.logger.debug("Panda url ssl: %s" % str(self.panda_url_ssl)) + if not self.panda_auth and 'PANDA_AUTH' in os.environ and os.environ['PANDA_AUTH']: + self.panda_auth = os.environ['PANDA_AUTH'] + if not self.panda_auth_vo and 'PANDA_AUTH_VO' in os.environ and os.environ['PANDA_AUTH_VO']: + self.panda_auth_vo = os.environ['PANDA_AUTH_VO'] + if not self.panda_config_root and 'PANDA_CONFIG_ROOT' in os.environ and os.environ['PANDA_CONFIG_ROOT']: + self.panda_config_root = os.environ['PANDA_CONFIG_ROOT'] def set_agent_attributes(self, attrs, req_attributes=None): if 'life_time' not in attrs[self.class_name] or int(attrs[self.class_name]['life_time']) <= 0: diff --git a/doma/lib/idds/doma/workflowv2/domapandawork.py b/doma/lib/idds/doma/workflowv2/domapandawork.py index 84e9dcfd..e2c85ae4 100644 --- a/doma/lib/idds/doma/workflowv2/domapandawork.py +++ b/doma/lib/idds/doma/workflowv2/domapandawork.py @@ -66,6 +66,9 @@ def __init__(self, executable=None, arguments=None, parameters=None, setup=None, self.panda_url = None self.panda_url_ssl = None self.panda_monitor = None + self.panda_auth = None + self.panda_auth_vo = None + self.panda_config_root = None self.dependency_map = dependency_map self.dependency_map_deleted = [] @@ -121,6 +124,9 @@ def load_panda_urls(self): self.panda_url = None self.panda_url_ssl = None self.panda_monitor = None + self.panda_auth = None + self.panda_auth_vo = None + self.panda_config_root = None if panda_config.has_section('panda'): if panda_config.has_option('panda', 'panda_monitor_url'): @@ -135,6 +141,15 @@ def load_panda_urls(self): self.panda_url_ssl = panda_config.get('panda', 'panda_url_ssl') os.environ['PANDA_URL_SSL'] = self.panda_url_ssl # self.logger.debug("Panda url ssl: %s" % str(self.panda_url_ssl)) + if panda_config.has_option('panda', 'panda_auth'): + self.panda_auth = panda_config.get('panda', 'panda_auth') + os.environ['PANDA_AUTH'] = self.panda_auth + if panda_config.has_option('panda', 'panda_auth_vo'): + self.panda_auth_vo = panda_config.get('panda', 'panda_auth_vo') + os.environ['PANDA_AUTH_VO'] = self.panda_auth_vo + if panda_config.has_option('panda', 'panda_config_root'): + self.panda_config_root = panda_config.get('panda', 'panda_config_root') + os.environ['PANDA_CONFIG_ROOT'] = self.panda_config_root if not self.panda_monitor and 'PANDA_MONITOR_URL' in os.environ and os.environ['PANDA_MONITOR_URL']: self.panda_monitor = os.environ['PANDA_MONITOR_URL'] @@ -145,6 +160,12 @@ def load_panda_urls(self): if not self.panda_url_ssl and 'PANDA_URL_SSL' in os.environ and os.environ['PANDA_URL_SSL']: self.panda_url_ssl = os.environ['PANDA_URL_SSL'] # self.logger.debug("Panda url ssl: %s" % str(self.panda_url_ssl)) + if not self.panda_auth and 'PANDA_AUTH' in os.environ and os.environ['PANDA_AUTH']: + self.panda_auth = os.environ['PANDA_AUTH'] + if not self.panda_auth_vo and 'PANDA_AUTH_VO' in os.environ and os.environ['PANDA_AUTH_VO']: + self.panda_auth_vo = os.environ['PANDA_AUTH_VO'] + if not self.panda_config_root and 'PANDA_CONFIG_ROOT' in os.environ and os.environ['PANDA_CONFIG_ROOT']: + self.panda_config_root = os.environ['PANDA_CONFIG_ROOT'] def set_agent_attributes(self, attrs, req_attributes=None): if 'life_time' not in attrs[self.class_name] or int(attrs[self.class_name]['life_time']) <= 0: diff --git a/main/etc/panda/panda.cfg b/main/etc/panda/panda.cfg index e6b1e29e..3f242cf1 100644 --- a/main/etc/panda/panda.cfg +++ b/main/etc/panda/panda.cfg @@ -3,9 +3,10 @@ # panda_url = http://ai-idds-01.cern.ch:25080/server/panda # panda_url_ssl = https://ai-idds-01.cern.ch:25443/server/panda -PANDA_AUTH = oidc -PANDA_URL_SSL = https://pandaserver-doma.cern.ch:25443/server/panda -PANDA_URL = http://pandaserver-doma.cern.ch:25080/server/panda -PANDAMON_URL = https://panda-doma.cern.ch -PANDA_AUTH_VO = panda_dev - +panda_url_ssl = https://pandaserver-doma.cern.ch:25443/server/panda +panda_url = http://pandaserver-doma.cern.ch:25080/server/panda +panda_monitor_url = https://panda-doma.cern.ch +# PANDA_AUTH_VO = panda_dev +panda_auth = oidc +panda_auth_vo = Rubin/production +panda_config_root = /tmp/.idds/ From 350ddc87e600dc745960594f1951353a6c8ded7d Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Fri, 28 Jan 2022 21:12:21 +0100 Subject: [PATCH 29/57] fix missing packages --- main/lib/idds/tests/test_domapanda.py | 2 +- main/tools/env/environment.yml | 1 + monitor/conf.js | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/main/lib/idds/tests/test_domapanda.py b/main/lib/idds/tests/test_domapanda.py index 7f7e3de8..1d056898 100644 --- a/main/lib/idds/tests/test_domapanda.py +++ b/main/lib/idds/tests/test_domapanda.py @@ -40,7 +40,7 @@ task_queue = 'DOMA_LSST_GOOGLE_TEST' task_queue = 'DOMA_LSST_GOOGLE_MERGE' task_queue = 'SLAC_TEST' -task_queue = 'DOMA_LSST_SLAC_TEST' +# task_queue = 'DOMA_LSST_SLAC_TEST' def randStr(chars=string.ascii_lowercase + string.digits, N=10): diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index c59be825..4bd785a0 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -3,6 +3,7 @@ dependencies: - python==3.9.7 - pip - pip: + - argcomplete - requests # requests - SQLAlchemy # db orm - urllib3 # url connections diff --git a/monitor/conf.js b/monitor/conf.js index 5fefd1c2..70840acd 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus784.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus784.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus784.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus784.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus784.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus784.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus755.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus755.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus755.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus755.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus755.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus755.cern.ch:443/idds/monitor/null/null/false/false/true" } From 817cfc25792ee17828039b89f24aeaa47df67c42 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Sun, 30 Jan 2022 17:30:24 +0100 Subject: [PATCH 30/57] update default memory requirements for doma panda work --- doma/lib/idds/doma/workflow/domapandawork.py | 2 +- doma/lib/idds/doma/workflowv2/domapandawork.py | 2 +- main/lib/idds/tests/test_domapanda.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doma/lib/idds/doma/workflow/domapandawork.py b/doma/lib/idds/doma/workflow/domapandawork.py index fe90f030..344298fb 100644 --- a/doma/lib/idds/doma/workflow/domapandawork.py +++ b/doma/lib/idds/doma/workflow/domapandawork.py @@ -46,7 +46,7 @@ def __init__(self, executable=None, arguments=None, parameters=None, setup=None, num_retries=5, task_log=None, task_cloud=None, - task_rss=0): + task_rss=1000): super(DomaPanDAWork, self).__init__(executable=executable, arguments=arguments, parameters=parameters, setup=setup, work_type=TransformType.Processing, diff --git a/doma/lib/idds/doma/workflowv2/domapandawork.py b/doma/lib/idds/doma/workflowv2/domapandawork.py index e2c85ae4..84e94186 100644 --- a/doma/lib/idds/doma/workflowv2/domapandawork.py +++ b/doma/lib/idds/doma/workflowv2/domapandawork.py @@ -48,7 +48,7 @@ def __init__(self, executable=None, arguments=None, parameters=None, setup=None, num_retries=5, task_log=None, task_cloud=None, - task_rss=0): + task_rss=1000): super(DomaPanDAWork, self).__init__(executable=executable, arguments=arguments, parameters=parameters, setup=setup, work_type=TransformType.Processing, diff --git a/main/lib/idds/tests/test_domapanda.py b/main/lib/idds/tests/test_domapanda.py index 1d056898..8670888a 100644 --- a/main/lib/idds/tests/test_domapanda.py +++ b/main/lib/idds/tests/test_domapanda.py @@ -38,8 +38,8 @@ task_queue = 'DOMA_LSST_GOOGLE_TEST' -task_queue = 'DOMA_LSST_GOOGLE_MERGE' -task_queue = 'SLAC_TEST' +# task_queue = 'DOMA_LSST_GOOGLE_MERGE' +# task_queue = 'SLAC_TEST' # task_queue = 'DOMA_LSST_SLAC_TEST' From 4e8ffeab16b3c3c5cc2ff3e39e15eff2810a0737 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:47:56 +0100 Subject: [PATCH 31/57] auth rest service for get and refresh tokens --- common/lib/idds/common/authentication.py | 398 +++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 common/lib/idds/common/authentication.py diff --git a/common/lib/idds/common/authentication.py b/common/lib/idds/common/authentication.py new file mode 100644 index 00000000..8889d77d --- /dev/null +++ b/common/lib/idds/common/authentication.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 - 2022 + +import datetime +import base64 +import json +import jwt +import os +import re +import requests + +try: + import ConfigParser +except ImportError: + import configparser as ConfigParser + +try: + # Python 2 + from urllib import urlencode +except ImportError: + # Python 3 + from urllib.parse import urlencode + raw_input = input + +# from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + +# from idds.common import exceptions +from idds.common.constants import HTTP_STATUS_CODE + + +def decode_value(val): + if isinstance(val, str): + val = val.encode() + decoded = base64.urlsafe_b64decode(val + b'==') + return int.from_bytes(decoded, 'big') + + +class BaseAuthentication(object): + def __init__(self, timeout=None): + self.timeout = timeout + self.config = self.load_auth_server_config() + self.max_expires_in = 60 + if self.config and self.config.has_section('common'): + if self.config.has_option('common', 'max_expires_in'): + self.max_expires_in = self.config.getint('common', 'max_expires_in') + + def load_auth_server_config(self): + config = ConfigParser.SafeConfigParser() + if os.environ.get('IDDS_AUTH_CONFIG', None): + configfile = os.environ['IDDS_AUTH_CONFIG'] + if config.read(configfile) == [configfile]: + return config + + configfiles = ['%s/etc/idds/auth/auth.cfg' % os.environ.get('IDDS_HOME', ''), + '/etc/idds/auth/auth.cfg', '/opt/idds/etc/idds/auth/auth.cfg', + '%s/etc/idds/auth/auth.cfg' % os.environ.get('VIRTUAL_ENV', '')] + for configfile in configfiles: + if config.read(configfile) == [configfile]: + return config + return config + + def get_allow_vos(self): + section = 'common' + allow_vos = [] + if self.config and self.config.has_section(section): + if self.config.has_option(section, 'allow_vos'): + allow_vos_temp = self.config.get(section, 'allow_vos') + allow_vos_temp = allow_vos_temp.split(',') + for t in allow_vos_temp: + t = t.strip() + allow_vos.append(t) + return allow_vos + + +class OIDCAuthentication(BaseAuthentication): + def __init__(self, timeout=None): + super(OIDCAuthentication, self).__init__(timeout=timeout) + + def get_auth_config(self, vo): + ret = {'vo': vo, 'oidc_config_url': None, 'client_id': None, + 'client_secret': None, 'audience': None} + + if self.config and self.config.has_section(vo): + for name in ['oidc_config_url', 'client_id', 'client_secret', 'vo', 'audience']: + if self.config.has_option(vo, name): + ret[name] = self.config.get(vo, name) + return ret + + def get_http_content(self, url): + try: + r = requests.get(url, allow_redirects=True) + return r.content + except Exception as error: + return False, 'Failed to get http content for %s: %s' (str(url), str(error)) + + def get_endpoint_config(self, auth_config): + content = self.get_http_content(auth_config['oidc_config_url']) + endpoint_config = json.loads(content) + # ret = {'token_endpoint': , 'device_authorization_endpoint': None} + return endpoint_config + + def get_oidc_sign_url(self, vo): + try: + allow_vos = self.get_allow_vos() + if vo not in allow_vos: + return False, "VO %s is not allowed." % vo + + auth_config = self.get_auth_config(vo) + endpoint_config = self.get_endpoint_config(auth_config) + + data = {'client_id': auth_config['client_id'], + 'scope': "openid profile email offline_access", + 'audience': auth_config['audience']} + + headers = {'content-type': 'application/x-www-form-urlencoded'} + + result = requests.session().post(endpoint_config['device_authorization_endpoint'], + # data=json.dumps(data), + urlencode(data).encode(), + timeout=self.timeout, + headers=headers) + + if result is not None: + if result.status_code == HTTP_STATUS_CODE.OK and result.text: + return True, json.loads(result.text) + else: + return False, "Failed to get oidc sign in URL (status: %s, text: %s)" % (result.status_code, result.text) + else: + return False, "Failed to get oidc sign in URL. Response is None." + except requests.exceptions.ConnectionError as error: + return False, 'Failed to get oidc sign in URL. ConnectionError: ' + str(error) + except Exception as error: + return False, 'Failed to get oidc sign in URL: ' + str(error) + + def get_id_token(self, vo, device_code, interval=5, expires_in=60): + try: + allow_vos = self.get_allow_vos() + if vo not in allow_vos: + return False, "VO %s is not allowed." % vo + + auth_config = self.get_auth_config(vo) + endpoint_config = self.get_endpoint_config(auth_config) + + data = {'client_id': auth_config['client_id'], + 'client_secret': auth_config['client_secret'], + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code} + + headers = {'content-type': 'application/x-www-form-urlencoded'} + + if not interval: + interval = 5 + interval = int(interval) + + if not expires_in: + expires_in = 60 + expires_in = int(expires_in) + if expires_in > self.max_expires_in: + expires_in = self.max_expires_in + + result = requests.session().post(endpoint_config['token_endpoint'], + # data=json.dumps(data), + urlencode(data).encode(), + timeout=self.timeout, + headers=headers) + if result is not None: + if result.status_code == HTTP_STATUS_CODE.OK and result.text: + return True, json.loads(result.text) + else: + return False, json.loads(result.text) + else: + return False, None + except Exception as error: + return False, 'Failed to get oidc token: ' + str(error) + + def refresh_id_token(self, vo, refresh_token): + try: + allow_vos = self.get_allow_vos() + if vo not in allow_vos: + return False, "VO %s is not allowed." % vo + + auth_config = self.get_auth_config(vo) + endpoint_config = self.get_endpoint_config(auth_config) + + data = {'client_id': auth_config['client_id'], + 'client_secret': auth_config['client_secret'], + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token} + + headers = {'content-type': 'application/x-www-form-urlencoded'} + + result = requests.session().post(endpoint_config['token_endpoint'], + # data=json.dumps(data), + urlencode(data).encode(), + timeout=self.timeout, + headers=headers) + + if result is not None: + if result.status_code == HTTP_STATUS_CODE.OK and result.text: + return True, json.loads(result.text) + else: + return False, "Failed to refresh oidc token (status: %s, text: %s)" % (result.status_code, result.text) + else: + return False, "Failed to refresh oidc token. Response is None." + except requests.exceptions.ConnectionError as error: + return False, 'Failed to refresh oidc token. ConnectionError: ' + str(error) + except Exception as error: + return False, 'Failed to refresh oidc token: ' + str(error) + + def get_public_key(self, token, jwks_uri): + headers = jwt.get_unverified_header(token) + if headers is None or 'kid' not in headers: + raise jwt.exceptions.InvalidTokenError('cannot extract kid from headers') + kid = headers['kid'] + + jwks_content = self.get_http_content(jwks_uri) + jwks = json.loads(jwks_content) + jwk = None + for j in jwks.get('keys', []): + if j.get('kid') == kid: + jwk = j + if jwk is None: + raise jwt.exceptions.InvalidTokenError('JWK not found for kid={0}'.format(kid, str(jwks))) + + public_num = RSAPublicNumbers(n=decode_value(jwk['n']), e=decode_value(jwk['e'])) + public_key = public_num.public_key(default_backend()) + pem = public_key.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo) + return pem + + def verify_id_token(self, vo, token): + try: + allow_vos = self.get_allow_vos() + if vo not in allow_vos: + return False, "VO %s is not allowed." % vo + + auth_config = self.get_auth_config(vo) + endpoint_config = self.get_endpoint_config(auth_config) + + # check audience + decoded_token = jwt.decode(token, verify=False, options={"verify_signature": False}) + audience = decoded_token['aud'] + if auth_config['client_id'] != audience: + # discovery_endpoint = auth_config['oidc_config_url'] + return False, "The audience of the token doesn't match vo configuration." + + public_key = self.get_public_key(token, endpoint_config['jwks_uri']) + # decode token only with RS256 + decoded = jwt.decode(token, public_key, verify=True, algorithms='RS256', + audience=audience, issuer=endpoint_config['issuer']) + decoded['vo'] = vo + return True, decoded + except Exception as error: + return False, 'Failed to verify oidc token: ' + str(error) + + +class OIDCAuthenticationUtils(object): + def __init__(self): + pass + + def save_token(self, path, token): + try: + with open(path, 'w') as f: + f.write(json.dumps(token)) + return True, None + except Exception as error: + return False, "Failed to save token: %s" % str(error) + + def load_token(self, path): + try: + with open(path) as f: + data = json.load(f) + return True, data + except Exception as error: + return False, "Failed to load token: %s" % str(error) + + def is_token_expired(self, token): + try: + enc = token['id_token'].split('.')[1] + enc += '=' * (-len(enc) % 4) + dec = json.loads(base64.urlsafe_b64decode(enc.encode())) + exp_time = datetime.datetime.utcfromtimestamp(dec['exp']) + # delta = exp_time - datetime.datetime.utcnow() + if exp_time < datetime.datetime.utcnow(): + return True, None + else: + return False, None + except Exception as error: + return True, "Failed to parse token: %s" % str(error) + + def clean_token(self, path): + try: + os.remove(path) + return True, None + except Exception as error: + return False, "Failed to clean token: %s" % str(error) + + def get_token_info(self, token): + try: + enc = token['id_token'].split('.')[1] + enc += '=' * (-len(enc) % 4) + dec = json.loads(base64.urlsafe_b64decode(enc.encode())) + exp_time = datetime.datetime.utcfromtimestamp(dec['exp']) + delta = exp_time - datetime.datetime.utcnow() + minutes = delta.total_seconds() / 60 + + info = dec + info['expire'] = exp_time + info['expire_time'] = 'Token will expire in %s minutes' % minutes + info['expire_at'] = 'Token will expire at {0} UTC'.format(exp_time.strftime("%Y-%m-%d %H:%M:%S")) + return True, info + except Exception as error: + return True, "Failed to parse token: %s" % str(error) + + +class X509Authentication(BaseAuthentication): + def __init__(self, timeout=None): + super(X509Authentication, self).__init__(timeout=timeout) + + def get_ban_user_list(self): + section = "Users" + option = "ban_users" + if self.config and self.config.has_section(section): + if self.config.has_option(option): + users = self.config.get(option) + users = users.split(",") + return users + return [] + + +def get_user_name_from_dn(dn): + try: + up = re.compile('/(DC|O|OU|C|L)=[^\/]+') # noqa W605 + username = up.sub('', dn) + up2 = re.compile('/CN=[0-9]+') + username = up2.sub('', username) + up3 = re.compile(' [0-9]+') + username = up3.sub('', username) + up4 = re.compile('_[0-9]+') + username = up4.sub('', username) + username = username.replace('/CN=proxy', '') + username = username.replace('/CN=limited proxy', '') + username = username.replace('limited proxy', '') + username = re.sub('/CN=Robot:[^/]+', '', username) + username = re.sub('/CN=nickname:[^/]+', '', username) + pat = re.compile('.*/CN=([^\/]+)/CN=([^\/]+)') # noqa W605 + mat = pat.match(username) + if mat: + username = mat.group(2) + else: + username = username.replace('/CN=', '') + if username.lower().find('/email') > 0: + username = username[:username.lower().find('/email')] + pat = re.compile('.*(limited.*proxy).*') + mat = pat.match(username) + if mat: + username = mat.group(1) + username = username.replace('(', '') + username = username.replace(')', '') + username = username.replace("'", '') + return username + except Exception: + return dn + + +def authenticate_x509(vo, dn, client_cert): + if not dn: + return False, "User DN cannot be found." + if not client_cert: + return False, "Client certificate proxy cannot be found." + + # certDecoded = x509.load_pem_x509_certificate(str.encode(client_cert), default_backend()) + # print(certDecoded.issuer) + # for ext in certDecoded.extensions: + # print(ext) + username = get_user_name_from_dn(dn) + ban_user_list = X509Authentication().get_ban_user_list() + if username in ban_user_list: + return False, "User %s is banned" % username + return True, None + + +def authenticate_oidc(vo, token): + oidc_auth = OIDCAuthentication() + status, data = oidc_auth.verify_id_token(vo, token) + if status: + return status, None + else: + return status, data From 65c3cf377f2ac9630a6086a1dcf344f41ee3a985 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:50:05 +0100 Subject: [PATCH 32/57] add ping rest service --- main/lib/idds/rest/v1/auth.py | 127 ++++++++++++++++++++++++++++++++++ main/lib/idds/rest/v1/ping.py | 52 ++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 main/lib/idds/rest/v1/auth.py create mode 100644 main/lib/idds/rest/v1/ping.py diff --git a/main/lib/idds/rest/v1/auth.py b/main/lib/idds/rest/v1/auth.py new file mode 100644 index 00000000..e3c510ab --- /dev/null +++ b/main/lib/idds/rest/v1/auth.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 + +import json +import traceback + +from flask import Blueprint + +from idds.common import exceptions +from idds.common.constants import HTTP_STATUS_CODE +from idds.common.authentication import OIDCAuthentication +from idds.rest.v1.controller import IDDSController + + +class OIDCAuthenticationSignURL(IDDSController): + """ OIDCAuthentication Sign URL""" + + def get(self, vo, auth_type='oidc'): + """ Get sign url for user to approve. + HTTP Success: + 200 OK + HTTP Error: + 404 Not Found + 500 InternalError + :returns: dictionary with sign url. + """ + + try: + if auth_type == 'oidc': + oidc = OIDCAuthentication() + sign_url = oidc.get_oidc_sign_url() + rets = sign_url + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=rets) + else: + raise exceptions.NotSupportedAuthentication("auth_type %s is not supported." % str(auth_type)) + except exceptions.NoObject as error: + return self.generate_http_response(HTTP_STATUS_CODE.NotFound, exc_cls=error.__class__.__name__, exc_msg=error) + except exceptions.IDDSException as error: + return self.generate_http_response(HTTP_STATUS_CODE.InternalError, exc_cls=error.__class__.__name__, exc_msg=error) + except Exception as error: + print(error) + print(traceback.format_exc()) + return self.generate_http_response(HTTP_STATUS_CODE.InternalError, exc_cls=exceptions.CoreException.__name__, exc_msg=error) + + +class OIDCAuthenticationToken(IDDSController): + """ OIDCAuthentication Token""" + + def get(self, vo, device_code, interval=5, expires_in=60): + """ Get id token. + HTTP Success: + 200 OK + HTTP Error: + 404 Not Found + 500 InternalError + :returns: dictionary with sign url. + """ + + try: + oidc = OIDCAuthentication() + id_token = oidc.get_id_token(vo, device_code, interval, expires_in) + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=id_token) + except exceptions.NoObject as error: + return self.generate_http_response(HTTP_STATUS_CODE.NotFound, exc_cls=error.__class__.__name__, exc_msg=error) + except exceptions.IDDSException as error: + return self.generate_http_response(HTTP_STATUS_CODE.InternalError, exc_cls=error.__class__.__name__, exc_msg=error) + except Exception as error: + print(error) + print(traceback.format_exc()) + return self.generate_http_response(HTTP_STATUS_CODE.InternalError, exc_cls=exceptions.CoreException.__name__, exc_msg=error) + + def post(self, vo): + """ Refresh the token. + HTTP Success: + 200 OK + HTTP Error: + 400 Bad request + 500 Internal Error + """ + try: + parameters = self.get_request().data and json.loads(self.get_request().data) + refresh_token = parameters['refresh_token'] + + oidc = OIDCAuthentication() + id_token = oidc.refresh_id_token(refresh_token) + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=id_token) + except exceptions.NoObject as error: + return self.generate_http_response(HTTP_STATUS_CODE.NotFound, exc_cls=error.__class__.__name__, exc_msg=error) + except exceptions.IDDSException as error: + return self.generate_http_response(HTTP_STATUS_CODE.InternalError, exc_cls=error.__class__.__name__, exc_msg=error) + except Exception as error: + print(error) + print(traceback.format_exc()) + return self.generate_http_response(HTTP_STATUS_CODE.InternalError, exc_cls=exceptions.CoreException.__name__, exc_msg=error) + + def post_test(self): + import pprint + pprint.pprint(self.get_request()) + pprint.pprint(self.get_request().endpoint) + pprint.pprint(self.get_request().url_rule) + + +"""---------------------- + Web service url maps +----------------------""" + + +def get_blueprint(): + bp = Blueprint('message', __name__) + + url_view = OIDCAuthenticationSignURL.as_view('url') + bp.add_url_rule('/auth/url/', view_func=url_view, methods=['get']) + bp.add_url_rule('/auth/url//', view_func=url_view, methods=['get']) + + token_view = OIDCAuthenticationToken.as_view('token') + bp.add_url_rule('/auth/token//', view_func=token_view, methods=['get']) + bp.add_url_rule('/auth/token///', view_func=token_view, methods=['get']) + bp.add_url_rule('/auth/token////', view_func=token_view, methods=['get']) + bp.add_url_rule('/auth/token/', view_func=token_view, methods=['post']) + return bp diff --git a/main/lib/idds/rest/v1/ping.py b/main/lib/idds/rest/v1/ping.py new file mode 100644 index 00000000..73860f6b --- /dev/null +++ b/main/lib/idds/rest/v1/ping.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2021 - 2022 + + +from flask import Blueprint + +# from idds.common import exceptions +from idds.common.constants import HTTP_STATUS_CODE +from idds.rest.v1.controller import IDDSController + + +class Ping(IDDSController): + """ Ping the rest service """ + + def get(self): + """ Ping the rest service. + HTTP Success: + 200 OK + HTTP Error: + 404 Not Found + 500 InternalError + :returns: dictionary of an request. + """ + + rets = {'Status': 'OK'} + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=rets) + + def post_test(self): + import pprint + pprint.pprint(self.get_request()) + pprint.pprint(self.get_request().endpoint) + pprint.pprint(self.get_request().url_rule) + + +"""---------------------- + Web service url maps +----------------------""" + + +def get_blueprint(): + bp = Blueprint('ping', __name__) + + view = Ping.as_view('ping') + bp.add_url_rule('/ping', view_func=view, methods=['get']) + return bp From b0a254e29a5bbcf390b69b501529dd17f7d39ff8 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:50:42 +0100 Subject: [PATCH 33/57] add auth client --- client/lib/idds/client/authclient.py | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 client/lib/idds/client/authclient.py diff --git a/client/lib/idds/client/authclient.py b/client/lib/idds/client/authclient.py new file mode 100644 index 00000000..4a091efe --- /dev/null +++ b/client/lib/idds/client/authclient.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2022 + + +""" +Auth Rest client to access IDDS system. +""" + +import os + +from idds.client.base import BaseRestClient +# from idds.common.constants import RequestType, RequestStatus + + +class AuthClient(BaseRestClient): + + """Authentication Rest client""" + + AUTH_BASEURL = 'auth' + + def __init__(self, host=None, auth=None, timeout=None): + """ + Constructor of the BaseRestClient. + + :param host: the address of the IDDS server. + :param client_proxy: the client certificate proxy. + :param timeout: timeout in seconds. + """ + super(AuthClient, self).__init__(host=host, auth=auth, timeout=timeout) + + def get_sign_url(self, vo): + """ + Get url from the Head service for users to sign in. + + :param vo: the virtual organization. + + :raise exceptions if it's not got successfully. + """ + path = self.AUTH_BASEURL + "/url" + url = self.build_url(self.host, path=os.path.join(path, str(vo), str(self.auth_type))) + + sign_url = self.get_request_response(url, type='GET', auth_setup_step=True) + + return sign_url + + def get_id_token(self, vo, device_code, interval=5, expires_in=60): + """ + Get token from the Head service. + + :param vo: the virtual organization. + :param device_code: the device code. + :param interval: the interval to poll the token. + :param expires_in: the time in seconds to expire for polling. + + :raise exceptions if it's not got successfully. + """ + path = self.AUTH_BASEURL + "/token" + url = self.build_url(self.host, path=os.path.join(path, str(vo), str(device_code), str(interval), str(expires_in))) + + token = self.get_request_response(url, type='GET', auth_setup_step=True) + + return token + + def refresh_id_token(self, vo, refresh_token): + """ + Refresh token from the Head service. + + :param vo: the virtual organization. + :param refresh_token: the token from refreshing. + + :raise exceptions if it's not got successfully. + """ + path = self.AUTH_BASEURL + "/token" + url = self.build_url(self.host, path=os.path.join(path, str(vo))) + + data = {'refresh_token': refresh_token} + token = self.get_request_response(url, type='POST', data=data) + + return token From 3fa097f61e02181f470f30ea8aa3d59fa8c38773 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:51:55 +0100 Subject: [PATCH 34/57] add hook before_request for auth --- client/lib/idds/client/pingclient.py | 47 +++++++++++++++++++++++ main/lib/idds/rest/v1/app.py | 56 ++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 client/lib/idds/client/pingclient.py diff --git a/client/lib/idds/client/pingclient.py b/client/lib/idds/client/pingclient.py new file mode 100644 index 00000000..bb439ac4 --- /dev/null +++ b/client/lib/idds/client/pingclient.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# +# 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.0OA +# +# Authors: +# - Wen Guan, , 2022 + + +""" +Ping Rest client to access IDDS system. +""" + +from idds.client.base import BaseRestClient +# from idds.common.constants import RequestType, RequestStatus + + +class PingClient(BaseRestClient): + + """Message Rest client""" + + PING_BASEURL = 'ping' + + def __init__(self, host=None, auth=None, timeout=None): + """ + Constructor of the BaseRestClient. + + :param host: the address of the IDDS server. + :param client_proxy: the client certificate proxy. + :param timeout: timeout in seconds. + """ + super(PingClient, self).__init__(host=host, auth=auth, timeout=timeout) + + def ping(self): + """ + Ping the Head service. + + :raise exceptions if it's not got successfully. + """ + path = self.PING_BASEURL + url = self.build_url(self.host, path=path) + + msgs = self.get_request_response(url, type='GET') + + return msgs diff --git a/main/lib/idds/rest/v1/app.py b/main/lib/idds/rest/v1/app.py index abdfecc8..27d80eba 100644 --- a/main/lib/idds/rest/v1/app.py +++ b/main/lib/idds/rest/v1/app.py @@ -12,9 +12,11 @@ Web service app ----------------------""" +import flask from flask import Flask, Response from idds.common import exceptions +from idds.common.authentication import authenticate_x509, authenticate_oidc from idds.common.constants import HTTP_STATUS_CODE from idds.common.utils import get_rest_debug # from idds.common.utils import get_rest_url_prefix @@ -25,6 +27,8 @@ from idds.rest.v1 import logs from idds.rest.v1 import monitor from idds.rest.v1 import messages +from idds.rest.v1 import ping +from idds.rest.v1 import auth class LoggingMiddleware(object): @@ -50,7 +54,7 @@ def log_response(status, headers, *args): return self._app(environ, log_response) -def get_blueprints(): +def get_normal_blueprints(): bps = [] bps.append(requests.get_blueprint()) bps.append(catalog.get_blueprint()) @@ -59,14 +63,60 @@ def get_blueprints(): bps.append(logs.get_blueprint()) bps.append(monitor.get_blueprint()) bps.append(messages.get_blueprint()) + bps.append(ping.get_blueprint()) + + return bps + + +def get_auth_blueprints(): + bps = [] + bps.append(auth.get_blueprint()) return bps -def create_app(): +def generate_failed_auth_response(exc_msg=None): + resp = Response(response=None, status=HTTP_STATUS_CODE.Unauthorized, content_type='application/json') + resp.headers['ExceptionClass'] = exceptions.IDDSException.__name__ + resp.headers['ExceptionMessage'] = exc_msg + return resp + + +def before_request_auth(): + auth_type = flask.request.headers.get('X-IDDS-Auth-Type', default='x509_proxy') + vo = flask.request.headers.get('X-IDDS-Auth-VO', default=None) + if auth_type in ['x509_proxy']: + dn = flask.request.environ.get('SSL_CLIENT_S_DN', None) + client_cert = flask.request.environ.get('SSL_CLIENT_CERT', None) + is_authenticated, errors = authenticate_x509(vo, dn, client_cert) + if not is_authenticated: + return generate_failed_auth_response(errors) + elif auth_type in ['oidc']: + token = flask.request.headers.get('X-IDDS-Auth-Token', default=None) + is_authenticated, errors = authenticate_oidc(vo, token) + if not is_authenticated: + return generate_failed_auth_response(errors) + else: + errors = "Authentication method %s is not supported" % auth_type + return generate_failed_auth_response(errors) + + +def after_request(response): + return response + + +def create_app(auth_type=None): # url_prefix = get_rest_url_prefix() - bps = get_blueprints() application = Flask(__name__) + + bps = get_auth_blueprints() + for bp in bps: + # application.register_blueprint(bp, url_prefix=url_prefix) + application.register_blueprint(bp) + + bps = get_normal_blueprints() for bp in bps: + bp.before_request(before_request_auth) + bp.after_request(after_request) # application.register_blueprint(bp, url_prefix=url_prefix) application.register_blueprint(bp) From 9757d1af8973eb712af2062830dcb6922b58e5fd Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:52:39 +0100 Subject: [PATCH 35/57] add function to support auth --- client/lib/idds/client/clientmanager.py | 175 ++++++++++++++++++------ 1 file changed, 130 insertions(+), 45 deletions(-) diff --git a/client/lib/idds/client/clientmanager.py b/client/lib/idds/client/clientmanager.py index 57853157..5f40738c 100644 --- a/client/lib/idds/client/clientmanager.py +++ b/client/lib/idds/client/clientmanager.py @@ -12,15 +12,26 @@ """ Workflow manager. """ +import datetime import os +import sys import logging import tabulate +import time try: import ConfigParser except ImportError: import configparser as ConfigParser +try: + from urllib import urlencode # noqa F401 +except ImportError: + from urllib.parse import urlencode # noqa F401 + raw_input = input + + +from idds.common.authentication import OIDCAuthenticationUtil from idds.common.utils import setup_logging, get_proxy_path from idds.client.version import release_version @@ -39,34 +50,35 @@ class ClientManager: - def __init__(self, host=None): + def __init__(self, host=None, timeout=600): self.host = host + self.timeout = timeout # if self.host is None: # self.host = get_rest_host() # self.client = Client(host=self.host) - self.local_config_root = None, - self.config = None, - self.auth_type = None, - self.x509_proxy = None, - - self.oidc_refresh_lifetime = None, - self.oidc_issuer = None, - self.oidc_audience = None, - self.oidc_token = None, - self.oidc_auto = None, - self.oidc_username = None, - self.oidc_password = None, - self.oidc_scope = None, - self.oidc_polling = None + self.local_config_root = None + self.config = None + self.auth_type = None + self.auth_type_host = None + self.x509_proxy = None + self.oidc_token = None + self.vo = None self.configuration = ConfigParser.SafeConfigParser() self.setup_local_configuration(host=host) if self.host is None: local_cfg = self.get_local_cfg_file() - self.host = self.get_config_value(local_cfg, 'rest', 'host', current=self.host, default=None) + self.host = self.get_config_value(local_cfg, self.auth_type, 'host', current=self.host, default=None) + if self.host is None: + self.host = self.get_config_value(local_cfg, 'rest', 'host', current=self.host, default=None) - self.client = Client(host=self.host, client_proxy=self.x509_proxy) + self.client = Client(host=self.host, + auth={'auth_type': self.auth_type, + 'client_proxy': self.x509_proxy, + 'oidc_token': self.oidc_token, + 'vo': self.vo}, + timeout=self.timeout) def get_local_config_root(self): local_cfg_root = get_local_config_root(self.local_config_root) @@ -84,7 +96,6 @@ def get_config_value(self, configuration, section, name, current, default): value = get_local_config_value(configuration, section, name, current, default) return value - @exception_handler def get_local_configuration(self): local_cfg = self.get_local_cfg_file() config = ConfigParser.SafeConfigParser() @@ -96,29 +107,21 @@ def get_local_configuration(self): self.auth_type = self.get_config_value(config, 'common', 'auth_type', current=self.auth_type, default='x509_proxy') self.host = self.get_config_value(config, 'rest', 'host', current=self.host, default=None) + self.auth_type_host = self.get_config_value(config, self.auth_type, 'host', current=self.auth_type_host, default=None) - self.x509_proxy = self.get_config_value(config, 'x509', 'x509_proxy', current=self.x509_proxy, + self.x509_proxy = self.get_config_value(config, 'x509_proxy', 'x509_proxy', current=self.x509_proxy, default='/tmp/x509up_u%d' % os.geteuid()) if not self.x509_proxy or not os.path.exists(self.x509_proxy): proxy = get_proxy_path() if proxy: self.x509_proxy = proxy - self.oidc_refresh_lifetime = self.get_config_value(config, 'oidc', 'oidc_refresh_lifetime', - current=self.oidc_refresh_lifetime, default=None) - self.oidc_issuer = self.get_config_value(config, 'oidc', 'oidc_issuer', current=self.oidc_audience, default=None) - self.oidc_audience = self.get_config_value(config, 'oidc', 'oidc_audience', current=self.oidc_audience, default=None) self.oidc_token = self.get_config_value(config, 'oidc', 'oidc_token', current=self.oidc_token, default=os.path.join(self.get_local_config_root(), '.oidc_token')) - self.oidc_auto = self.get_config_value(config, 'oidc', 'oidc_auto', current=self.oidc_auto, default=False) - self.oidc_username = self.get_config_value(config, 'oidc', 'oidc_username', current=self.oidc_username, default=None) - self.oidc_password = self.get_config_value(config, 'oidc', 'oidc_password', current=self.oidc_password, default=None) - self.oidc_scope = self.get_config_value(config, 'oidc', 'oidc_scope', current=self.oidc_scope, default='openid profile') - self.oidc_polling = self.get_config_value(config, 'oidc', 'oidc_polling', current=self.oidc_polling, default=False) + self.vo = self.get_config_value(config, self.auth_type, 'vo', current=self.vo, default=None) self.configuration = config - @exception_handler def save_local_configuration(self): local_cfg = self.get_local_cfg_file() with open(local_cfg, 'w') as configfile: @@ -126,11 +129,8 @@ def save_local_configuration(self): @exception_handler def setup_local_configuration(self, local_config_root=None, config=None, host=None, - auth_type=None, x509_proxy=None, - oidc_refresh_lifetime=None, oidc_issuer=None, - oidc_audience=None, oidc_token=None, - oidc_auto=None, oidc_username=None, oidc_password=None, - oidc_scope=None, oidc_polling=None): + auth_type=None, auth_type_host=None, x509_proxy=None, + oidc_token=None, vo=None): if 'IDDS_CONFIG' in os.environ and os.environ['IDDS_CONFIG']: if config is None: @@ -143,16 +143,10 @@ def setup_local_configuration(self, local_config_root=None, config=None, host=No self.config = config self.host = host self.auth_type = auth_type + self.auth_type_host = auth_type_host self.x509_proxy = x509_proxy - self.oidc_refresh_lifetime = oidc_refresh_lifetime - self.oidc_issuer = oidc_issuer - self.oidc_audience = oidc_audience self.oidc_token = oidc_token - self.oidc_auto = oidc_auto - self.oidc_username = oidc_username - self.oidc_password = oidc_password - self.oidc_scope = oidc_scope - self.oidc_polling = oidc_polling + self.vo = vo self.get_local_configuration() self.save_local_configuration() @@ -162,21 +156,112 @@ def setup_oidc_token(self): """" Setup oidc token """ - pass + sign_url = self.client.get_oidc_sign_url(self.vo) + logging.info(("Please go to {0} and sign in. " + "Waiting until authentication is completed").format(sign_url['verification_uri_complete'])) + + logging.info('Ready to get ID token?') + while True: + sys.stdout.write("[y/n] \n") + choice = raw_input().lower() + if choice == 'y': + break + elif choice == 'n': + logging.info('aborted') + return + + if 'interval' in sign_url: + interval = sign_url['interval'] + else: + interval = 5 + + if 'expires_in' in sign_url: + expires_in = sign_url['expires_in'] + else: + expires_in = 60 + + token = None + start_time = datetime.datetime.utcnow() + while datetime.datetime.utcnow() - start_time < datetime.timedelta(seconds=expires_in): + try: + status, output = self.client.get_id_token(self.vo, sign_url['device_code']) + logging.debug("get_id_token: status: %s, output: %s" % (status, output)) + if status: + token = output + break + else: + if type(output) in [dict] and 'error' in output and output['error'] == 'authorization_pending': + logging.debug("get_id_token: pending: %s" % str(output)) + time.sleep(interval) + else: + logging.error("get_id_token: unknown error: %s" % str(output)) + break + except Exception as error: + logging.error("get_id_token: exception: %s" % str(error)) + break + + if not token: + logging.error("Failed to get token.") + else: + oidc_util = OIDCAuthenticationUtil() + status, output = oidc_util.save_token(self.oidc_token, token) + if status: + logging.info("Token is saved to %s" % (self.oidc_token)) + else: + logging.info("Failed to save token to %s: (status: %s, output: %s)" % (self.oidc_token, status, output)) + + @exception_handler + def refresh_oidc_token(self): + """" + refresh oidc token + """ + oidc_util = OIDCAuthenticationUtil() + status, token = oidc_util.load_token(self.oidc_token) + if not status: + logging.error("Token %s cannot be loaded: %s" % (status, token)) + return + + is_expired, output = oidc_util.is_token_expired(token) + if is_expired: + logging.error("Token %s is already expired(%s). Cannot refresh." % self.oidc_token, output) + else: + new_token = self.client.refresh_id_token(self.vo, token['refresh_token']) + status, data = oidc_util.save_token(self.oidc_token, new_token) + if status: + logging.info("New token saved to %s" % self.oidc_token) + else: + logging.info("Failed to save token to %s: %s" % (self.oidc_token, data)) @exception_handler def clean_oidc_token(self): """" Clean oidc token """ - pass + oidc_util = OIDCAuthenticationUtil() + status, output = oidc_util.clean_token(self.oidc_token) + if status: + logging.info("Token %s is cleaned" % self.oidc_token) + else: + logging.error("Failed to clean token %s: status: %s, output: %s" % (self.oidc_token, status, output)) @exception_handler def check_oidc_token_status(self): """" Check oidc token status """ - pass + oidc_util = OIDCAuthenticationUtil() + status, token = oidc_util.load_token(self.oidc_token) + if not status: + logging.error("Token %s cannot be loaded: %s" % (status, token)) + return + + status, token_info = oidc_util.get_token_info(token) + if status: + logging.info("Token path: %s" % self.oidc_token) + for k in token_info: + logging.info("Token %s: %s" % (k, token_info[k])) + else: + logging.error("Failed to parse token information: %s" % str(token_info)) @exception_handler def submit(self, workflow, username=None, userdn=None, use_dataset_name=True): From ecb0fada3834ea90e0f0c5aa94dd30270d329a9d Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:57:36 +0100 Subject: [PATCH 36/57] client to send different info for different auth --- client/lib/idds/client/base.py | 95 +++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/client/lib/idds/client/base.py b/client/lib/idds/client/base.py index bac44376..5ee4ece6 100644 --- a/client/lib/idds/client/base.py +++ b/client/lib/idds/client/base.py @@ -15,6 +15,7 @@ import logging +import os import requests try: # Python 2 @@ -26,13 +27,14 @@ from idds.common import exceptions from idds.common.constants import HTTP_STATUS_CODE from idds.common.utils import json_dumps, json_loads +from idds.common.authentication import OIDCAuthenticationUtils class BaseRestClient(object): """Base Rest client""" - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None, client_proxy=None): """ Constructor of the BaseRestClient. @@ -42,11 +44,46 @@ def __init__(self, host=None, client_proxy=None, timeout=None): """ self.host = host + self.auth = auth self.client_proxy = client_proxy self.timeout = timeout self.session = requests.session() self.retries = 2 + self.auth_type = None + self.oidc_token = None + self.vo = None + if self.auth: + if 'auth_type' in self.auth: + self.auth_type = self.auth['auth_type'] + if 'client_proxy' in self.auth: + self.client_proxy = self.auth['client_proxy'] + if 'oidc_token' in self.auth: + self.oidc_token = self.auth['oidc_token'] + if 'vo' in self.auth: + self.vo = self.auth['vo'] + + self.check_auth() + + def check_auth(self): + """ + To check whether the auth type is supported and the input for the auth is available. + """ + if not self.auth_type: + logging.warn("auth_type is not set, will use x509_proxy") + self.auth_type = 'x509_proxy' + + if self.auth_type in ['x509_proxy']: + if not self.client_proxy or not os.path.exists(self.client_proxy): + raise exceptions.RestException("Cannot find a valid x509 proxy.") + elif self.auth_type in ['oidc']: + if not self.oidc_token or not os.path.exists(self.oidc_token): + raise exceptions.RestException("Cannot find oidc token.") + if not self.vo: + raise exceptions.RestException("vo is not defined for oidc authentication.") + else: + logging.error("auth_type %s is not supported." % str(self.auth_type)) + def build_url(self, url, path=None, params=None, doseq=False): """ Build url path. @@ -68,7 +105,7 @@ def build_url(self, url, path=None, params=None, doseq=False): full_url += urlencode(params, doseq=doseq) return full_url - def get_request_response(self, url, type='GET', data=None, headers=None): + def get_request_response(self, url, type='GET', data=None, headers=None, auth_setup_step=False): """ Send request to the IDDS server and get the response. @@ -82,19 +119,53 @@ def get_request_response(self, url, type='GET', data=None, headers=None): """ result = None + if not headers: + headers = {} + headers['X-IDDS-Auth-Type'] = self.auth_type + headers['X-IDDS-Auth-VO'] = self.vo for retry in range(self.retries): try: - if type == 'GET': - result = self.session.get(url, cert=(self.client_proxy, self.client_proxy), timeout=self.timeout, headers=headers, verify=False) - elif type == 'PUT': - result = self.session.put(url, cert=(self.client_proxy, self.client_proxy), data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) - elif type == 'POST': - result = self.session.post(url, cert=(self.client_proxy, self.client_proxy), data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) - elif type == 'DEL': - result = self.session.delete(url, cert=(self.client_proxy, self.client_proxy), data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) - else: - return + if self.auth_type in ['x509_proxy']: + if type == 'GET': + result = self.session.get(url, cert=(self.client_proxy, self.client_proxy), timeout=self.timeout, headers=headers, verify=False) + elif type == 'PUT': + result = self.session.put(url, cert=(self.client_proxy, self.client_proxy), data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + elif type == 'POST': + result = self.session.post(url, cert=(self.client_proxy, self.client_proxy), data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + elif type == 'DEL': + result = self.session.delete(url, cert=(self.client_proxy, self.client_proxy), data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + else: + return + elif self.auth_type in ['oidc']: + if auth_setup_step: + if type == 'GET': + result = self.session.get(url, timeout=self.timeout, headers=headers, verify=False) + elif type == 'PUT': + result = self.session.put(url, data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + elif type == 'POST': + result = self.session.post(url, data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + elif type == 'DEL': + result = self.session.delete(url, data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + else: + return + else: + token = OIDCAuthenticationUtils.load_token(self.oidc_token) + is_expired = OIDCAuthenticationUtils.is_token_expired(token) + if is_expired: + raise exceptions.IDDSException("Token is already expired.") + headers['X-IDDS-Auth-Token'] = token['id_token'] + + if type == 'GET': + result = self.session.get(url, timeout=self.timeout, headers=headers, verify=False) + elif type == 'PUT': + result = self.session.put(url, data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + elif type == 'POST': + result = self.session.post(url, data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + elif type == 'DEL': + result = self.session.delete(url, data=json_dumps(data), timeout=self.timeout, headers=headers, verify=False) + else: + return except requests.exceptions.ConnectionError as error: logging.warning('ConnectionError: ' + str(error)) if retry >= self.retries - 1: From 32847df658c5f1ff7cc12fc307c643cbcdb5fa85 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 21:58:32 +0100 Subject: [PATCH 37/57] modify client init to support different auth --- client/bin/idds | 42 ++++++++----------- client/lib/idds/client/cacherclient.py | 4 +- client/lib/idds/client/catalogclient.py | 4 +- client/lib/idds/client/client.py | 8 ++-- client/lib/idds/client/hpoclient.py | 4 +- client/lib/idds/client/logsclient.py | 4 +- client/lib/idds/client/messageclient.py | 4 +- client/lib/idds/client/requestclient.py | 4 +- common/tools/env/environment.yml | 4 +- .../httpd-idds-443-py39-cc7.conf.template | 21 +++++----- main/setup.py | 1 + main/tools/env/setup_panda.sh | 3 +- monitor/conf.js | 12 +++--- 13 files changed, 58 insertions(+), 57 deletions(-) diff --git a/client/bin/idds b/client/bin/idds index 3d37216d..2d54bc23 100755 --- a/client/bin/idds +++ b/client/bin/idds @@ -6,7 +6,7 @@ # http://www.apache.org/licenses/LICENSE-2.0OA # # Authors: -# - Wen Guan, , 2020 - 2021 +# - Wen Guan, , 2020 - 2022 """ iDDS CLI @@ -31,23 +31,15 @@ def setup(args): cm.setup_local_configuration(local_config_root=args.local_config_root, config=args.config, host=args.host, auth_type=args.auth_type, - x509_proxy=args.x509_proxy) + auth_type_host=args.auth_type_host, + x509_proxy=args.x509_proxy, + vo=args.vo, + oidc_token=args.oidc_token) return cm def setup_oidc_token(args): cm = ClientManager(host=args.host) - cm.setup_local_configuration(local_config_root=args.local_config_root, - config=args.config, host=args.host, - oidc_refresh_lifetime=args.oidc_refresh_lifetime, - oidc_issuer=args.oidc_issuer, - oidc_audience=args.oidc_audience, - oidc_token=args.oidc_token, - oidc_auto=args.oidc_auto, - oidc_username=args.oidc_username, - oidc_password=args.oidc_password, - oidc_scope=args.oidc_scope, - oidc_polling=args.oidc_polling) cm.setup_oidc_token() @@ -146,22 +138,24 @@ def get_parser(): # setup setup_parser = subparsers.add_parser('setup', help='Setup local configuration') setup_parser.set_defaults(function=setup) - setup_parser.add_argument('-H', '--host', dest="host", metavar="ADDRESS", help="The iDDS Rest host. For example: https://hostname:443/idds") - setup_parser.add_argument('--auth_type', dest='auth_type', action='store', default=None, help='The auth_type in [x509_proxy, oidc, saml]. Default is x509_proxy.') + setup_parser.add_argument('--host', dest="auth_type_host", metavar="ADDRESS", help="The iDDS Rest host for the current auth type. For example: https://hostname:443/idds") + setup_parser.add_argument('--auth_type', dest='auth_type', action='store', default=None, help='The auth_type in [x509_proxy, oidc]. Default is x509_proxy.') setup_parser.add_argument('--x509_proxy', dest='x509_proxy', action='store', default=None, help='The x509 proxy path. Default is /tmp/x509up_u%d.' % os.geteuid()) + setup_parser.add_argument('--vo', dest='vo', action='store', default=None, help='The virtual organization for authentication.') + setup_parser.add_argument('--oidc_token', dest='oidc_token', default=None, help='The oidc token path. Default is {local_config_root}/.oidc_token.') # setup token token_setup_parser = subparsers.add_parser('setup_oidc_token', help='Setup authentication token') token_setup_parser.set_defaults(function=setup_oidc_token) - token_setup_parser.add_argument('--oidc_refresh_lifetime', dest='oidc_refresh_lifetime', default=None, help='The oidc refresh lifetime') - token_setup_parser.add_argument('--oidc_issuer', dest='oidc_issuer', default=None, help='The oidc issuer') - token_setup_parser.add_argument('--oidc_audience', dest='oidc_audience', default=None, help='The oidc audience') - token_setup_parser.add_argument('--oidc_token', dest='oidc_token', default=None, help='The oidc token path. Default is {local_config_root}/.oidc_token.') - token_setup_parser.add_argument('--oidc_auto', dest='oidc_auto', default=False, action='store_true', help='Get oidc token automatically, requiring oidc_username and oidc_password') - token_setup_parser.add_argument('--oidc_username', dest='oidc_username', default=None, help='The oidc username for getting oidc token, with --oidc_auto') - token_setup_parser.add_argument('--oidc_password', dest='oidc_password', default=None, help='The oidc password for getting oidc token, with --oidc_auto') - token_setup_parser.add_argument('--oidc_scope', dest='oidc_scope', default=None, help='The oidc scope. Default is openid profile.') - token_setup_parser.add_argument('--oidc_polling', dest='oidc_polling', default=False, help='whether polling oidc') + # token_setup_parser.add_argument('--oidc_refresh_lifetime', dest='oidc_refresh_lifetime', default=None, help='The oidc refresh lifetime') + # token_setup_parser.add_argument('--oidc_issuer', dest='oidc_issuer', default=None, help='The oidc issuer') + # token_setup_parser.add_argument('--oidc_audience', dest='oidc_audience', default=None, help='The oidc audience') + # token_setup_parser.add_argument('--oidc_token', dest='oidc_token', default=None, help='The oidc token path. Default is {local_config_root}/.oidc_token.') + # token_setup_parser.add_argument('--oidc_auto', dest='oidc_auto', default=False, action='store_true', help='Get oidc token automatically, requiring oidc_username and oidc_password') + # token_setup_parser.add_argument('--oidc_username', dest='oidc_username', default=None, help='The oidc username for getting oidc token, with --oidc_auto') + # token_setup_parser.add_argument('--oidc_password', dest='oidc_password', default=None, help='The oidc password for getting oidc token, with --oidc_auto') + # token_setup_parser.add_argument('--oidc_scope', dest='oidc_scope', default=None, help='The oidc scope. Default is openid profile.') + # token_setup_parser.add_argument('--oidc_polling', dest='oidc_polling', default=False, help='whether polling oidc') # token_setup_parser.add_argument('--saml_username', dest='saml_username', default=None, help='The SAML username') # token_setup_parser.add_argument('--saml_password', dest='saml_password', default=None, help='The saml password') diff --git a/client/lib/idds/client/cacherclient.py b/client/lib/idds/client/cacherclient.py index 4287fcdd..714b2586 100644 --- a/client/lib/idds/client/cacherclient.py +++ b/client/lib/idds/client/cacherclient.py @@ -25,7 +25,7 @@ class CacherClient(BaseRestClient): CACHER_BASEURL = 'cacher' - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None): """ Constructor of the BaseRestClient. @@ -33,7 +33,7 @@ def __init__(self, host=None, client_proxy=None, timeout=None): :param client_proxy: the client certificate proxy. :param timeout: timeout in seconds. """ - super(CacherClient, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(CacherClient, self).__init__(host=host, auth=auth, timeout=timeout) def upload(self, filename): """ diff --git a/client/lib/idds/client/catalogclient.py b/client/lib/idds/client/catalogclient.py index e4c13118..0a3f2350 100644 --- a/client/lib/idds/client/catalogclient.py +++ b/client/lib/idds/client/catalogclient.py @@ -25,7 +25,7 @@ class CatalogClient(BaseRestClient): CATALOG_BASEURL = 'catalog' - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None): """ Constructor of the BaseRestClient. @@ -33,7 +33,7 @@ def __init__(self, host=None, client_proxy=None, timeout=None): :param client_proxy: the client certificate proxy. :param timeout: timeout in seconds. """ - super(CatalogClient, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(CatalogClient, self).__init__(host=host, auth=auth, timeout=timeout) def get_collections(self, scope=None, name=None, request_id=None, workload_id=None, relation_type=None): """ diff --git a/client/lib/idds/client/client.py b/client/lib/idds/client/client.py index 67542aa1..d9b91af1 100644 --- a/client/lib/idds/client/client.py +++ b/client/lib/idds/client/client.py @@ -25,16 +25,18 @@ from idds.client.hpoclient import HPOClient from idds.client.logsclient import LogsClient from idds.client.messageclient import MessageClient +from idds.client.pingclient import PingClient +from idds.client.authclient import AuthClient warnings.filterwarnings("ignore") -class Client(RequestClient, CatalogClient, CacherClient, HPOClient, LogsClient, MessageClient): +class Client(RequestClient, CatalogClient, CacherClient, HPOClient, LogsClient, MessageClient, PingClient, AuthClient): """Main client class for IDDS rest callings.""" - def __init__(self, host=None, timeout=600, client_proxy=None): + def __init__(self, host=None, timeout=600, auth=None, client_proxy=None): """ Constructor for the IDDS main client class. @@ -44,7 +46,7 @@ def __init__(self, host=None, timeout=600, client_proxy=None): # if client_proxy is None: # client_proxy = self.get_user_proxy() - super(Client, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(Client, self).__init__(host=host, auth=auth, timeout=timeout) def get_user_proxy(sellf): """ diff --git a/client/lib/idds/client/hpoclient.py b/client/lib/idds/client/hpoclient.py index f3d3c5df..324be48b 100644 --- a/client/lib/idds/client/hpoclient.py +++ b/client/lib/idds/client/hpoclient.py @@ -25,7 +25,7 @@ class HPOClient(BaseRestClient): HPO_BASEURL = 'hpo' - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None): """ Constructor of the BaseRestClient. @@ -33,7 +33,7 @@ def __init__(self, host=None, client_proxy=None, timeout=None): :param client_proxy: the client certificate proxy. :param timeout: timeout in seconds. """ - super(HPOClient, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(HPOClient, self).__init__(host=host, auth=auth, timeout=timeout) def update_hyperparameter(self, workload_id, request_id, id, loss): """ diff --git a/client/lib/idds/client/logsclient.py b/client/lib/idds/client/logsclient.py index 5edc2a4e..8c00a47e 100644 --- a/client/lib/idds/client/logsclient.py +++ b/client/lib/idds/client/logsclient.py @@ -26,7 +26,7 @@ class LogsClient(BaseRestClient): LOGS_BASEURL = 'logs' - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None): """ Constructor of the BaseRestClient. @@ -34,7 +34,7 @@ def __init__(self, host=None, client_proxy=None, timeout=None): :param client_proxy: the client certificate proxy. :param timeout: timeout in seconds. """ - super(LogsClient, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(LogsClient, self).__init__(host=host, auth=auth, timeout=timeout) def download_logs(self, workload_id=None, request_id=None, dest_dir='./', filename=None): """ diff --git a/client/lib/idds/client/messageclient.py b/client/lib/idds/client/messageclient.py index 2c1ff141..6f4ed541 100644 --- a/client/lib/idds/client/messageclient.py +++ b/client/lib/idds/client/messageclient.py @@ -25,7 +25,7 @@ class MessageClient(BaseRestClient): MESSAGE_BASEURL = 'message' - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None): """ Constructor of the BaseRestClient. @@ -33,7 +33,7 @@ def __init__(self, host=None, client_proxy=None, timeout=None): :param client_proxy: the client certificate proxy. :param timeout: timeout in seconds. """ - super(MessageClient, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(MessageClient, self).__init__(host=host, auth=auth, timeout=timeout) def send_message(self, request_id=None, workload_id=None, msg=None): """ diff --git a/client/lib/idds/client/requestclient.py b/client/lib/idds/client/requestclient.py index aad283db..040b23a7 100644 --- a/client/lib/idds/client/requestclient.py +++ b/client/lib/idds/client/requestclient.py @@ -25,7 +25,7 @@ class RequestClient(BaseRestClient): REQUEST_BASEURL = 'request' - def __init__(self, host=None, client_proxy=None, timeout=None): + def __init__(self, host=None, auth=None, timeout=None): """ Constructor of the BaseRestClient. @@ -33,7 +33,7 @@ def __init__(self, host=None, client_proxy=None, timeout=None): :param client_proxy: the client certificate proxy. :param timeout: timeout in seconds. """ - super(RequestClient, self).__init__(host=host, client_proxy=client_proxy, timeout=timeout) + super(RequestClient, self).__init__(host=host, auth=auth, timeout=timeout) def add_request(self, **kwargs): """ diff --git a/common/tools/env/environment.yml b/common/tools/env/environment.yml index a25608ff..41e3db72 100644 --- a/common/tools/env/environment.yml +++ b/common/tools/env/environment.yml @@ -3,8 +3,10 @@ dependencies: - python==3.6 - pip - pip: + - cryptography + - pyjwt # Pyjwt - unittest2 # unit test tool - pep8 # checks for PEP8 code style compliance - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - - nose # nose test tools \ No newline at end of file + - nose # nose test tools diff --git a/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template b/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template index 277f7f21..b4abafda 100644 --- a/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template +++ b/main/etc/idds/rest/httpd-idds-443-py39-cc7.conf.template @@ -28,17 +28,8 @@ LoadModule wsgi_module {python_site_packages_path}/mod_wsgi/server/mod_wsgi-py39 WSGIPythonHome {python_site_home_path} WSGIPythonPath {python_site_packages_path} - - WSGIDaemonProcess idds_daemon processes=25 threads=2 request-timeout=600 queue-timeout=600 python-home={python_site_home_path} python-path={python_site_packages_path} - WSGIProcessGroup idds_daemon - WSGIApplicationGroup %{GLOBAL} - WSGIScriptAlias /idds {python_site_bin_path}/idds.wsgi - # WSGIScriptAliasMatch ^/idds/(.+)$ /opt/idds/etc/idds/rest/test.wsgi - WSGISocketPrefix /var/log/idds/wsgisocks/wsgi - WSGIPassAuthorization On - - Listen 443 +Listen 8443 RewriteEngine on RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK) @@ -71,6 +62,16 @@ Alias "/monitor" "/opt/idds/monitor" ErrorLog /var/log/idds/httpd_error_log TransferLog /var/log/idds/httpd_access_log + + WSGIDaemonProcess idds_daemon processes=25 threads=2 request-timeout=600 queue-timeout=600 python-home={python_site_home_path} python-path={python_site_packages_path} + WSGIProcessGroup idds_daemon + WSGIApplicationGroup %{GLOBAL} + WSGIScriptAlias /idds {python_site_bin_path}/idds.wsgi + # WSGIScriptAliasMatch ^/idds/(.+)$ /opt/idds/etc/idds/rest/test.wsgi + WSGISocketPrefix /var/log/idds/wsgisocks/wsgi + WSGIPassAuthorization On + + # Proxy authentication via mod_gridsite GridSiteIndexes on diff --git a/main/setup.py b/main/setup.py index 89806393..765e64e8 100644 --- a/main/setup.py +++ b/main/setup.py @@ -118,6 +118,7 @@ def replace_data_path(wsgi_file, install_data_path): # config and cron files ('etc/idds/', glob.glob('etc/idds/*.template')), ('etc/idds/rest', glob.glob('etc/idds/rest/*template')), + ('etc/idds/auth', glob.glob('etc/idds/auth/*template')), ('tools/env/', glob.glob('tools/env/*')), ] scripts = glob.glob('bin/*') diff --git a/main/tools/env/setup_panda.sh b/main/tools/env/setup_panda.sh index 943a3827..f16611a3 100644 --- a/main/tools/env/setup_panda.sh +++ b/main/tools/env/setup_panda.sh @@ -6,5 +6,6 @@ export PANDA_URL=http://pandaserver-doma.cern.ch:25080/server/panda export PANDAMON_URL=https://panda-doma.cern.ch export PANDA_AUTH_VO=panda_dev -export PANDA_CONFIG_ROOT=/afs/cern.ch/user/w/wguan/workdisk/iDDS/main/etc/panda/ +# export PANDA_CONFIG_ROOT=/afs/cern.ch/user/w/wguan/workdisk/iDDS/main/etc/panda/ +export PANDA_CONFIG_ROOT=~/.panda/ diff --git a/monitor/conf.js b/monitor/conf.js index 70840acd..9042281f 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus755.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus755.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus755.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus755.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus755.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus755.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus766.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus766.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus766.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus766.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus766.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus766.cern.ch:443/idds/monitor/null/null/false/false/true" } From 7897541041c17524d792cfe157d87e306be9f67f Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 8 Feb 2022 22:07:23 +0100 Subject: [PATCH 38/57] add auth cfg template --- main/etc/idds/auth/auth.cfg.template | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 main/etc/idds/auth/auth.cfg.template diff --git a/main/etc/idds/auth/auth.cfg.template b/main/etc/idds/auth/auth.cfg.template new file mode 100644 index 00000000..10e9b48f --- /dev/null +++ b/main/etc/idds/auth/auth.cfg.template @@ -0,0 +1,26 @@ +[common] +allow_vos = panda_dev,Rubin,Rubin:production + +[panda_dev] +client_secret = <> +audience = https://pandaserver-doma.cern.ch +client_id = <> +oidc_config_url = https://panda-iam-doma.cern.ch/.well-known/openid-configuration +vo = panda_dev + +[Rubin] +client_secret = <> +audience = https://pandaserver-doma.cern.ch +client_id = <> +oidc_config_url = https://panda-iam-doma.cern.ch/.well-known/openid-configuration +vo = Rubin + +[Rubin:production] +client_secret = <> +audience = https://pandaserver-doma.cern.ch +client_id = <> +oidc_config_url = https://panda-iam-doma.cern.ch/.well-known/openid-configuration +vo = Rubin + +[Users] +ban_users = testuser From 74b6b121cc803e0f645efd26692bd63d2aa5a956 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 19:59:20 +0100 Subject: [PATCH 39/57] fix auth client --- client/lib/idds/client/authclient.py | 2 +- client/lib/idds/client/base.py | 53 +++++++++++++++++------- common/lib/idds/common/authentication.py | 2 +- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/client/lib/idds/client/authclient.py b/client/lib/idds/client/authclient.py index 4a091efe..e02c1d5f 100644 --- a/client/lib/idds/client/authclient.py +++ b/client/lib/idds/client/authclient.py @@ -35,7 +35,7 @@ def __init__(self, host=None, auth=None, timeout=None): """ super(AuthClient, self).__init__(host=host, auth=auth, timeout=timeout) - def get_sign_url(self, vo): + def get_oidc_sign_url(self, vo): """ Get url from the Head service for users to sign in. diff --git a/client/lib/idds/client/base.py b/client/lib/idds/client/base.py index 5ee4ece6..1a4efffd 100644 --- a/client/lib/idds/client/base.py +++ b/client/lib/idds/client/base.py @@ -53,6 +53,7 @@ def __init__(self, host=None, auth=None, timeout=None, client_proxy=None): self.auth_type = None self.oidc_token = None self.vo = None + self.auth_setup = False if self.auth: if 'auth_type' in self.auth: self.auth_type = self.auth['auth_type'] @@ -62,6 +63,8 @@ def __init__(self, host=None, auth=None, timeout=None, client_proxy=None): self.oidc_token = self.auth['oidc_token'] if 'vo' in self.auth: self.vo = self.auth['vo'] + if 'auth_setup' in self.auth: + self.auth_setup = self.auth['auth_setup'] self.check_auth() @@ -77,10 +80,11 @@ def check_auth(self): if not self.client_proxy or not os.path.exists(self.client_proxy): raise exceptions.RestException("Cannot find a valid x509 proxy.") elif self.auth_type in ['oidc']: - if not self.oidc_token or not os.path.exists(self.oidc_token): - raise exceptions.RestException("Cannot find oidc token.") - if not self.vo: - raise exceptions.RestException("vo is not defined for oidc authentication.") + if not self.auth_setup: + if not self.oidc_token or not os.path.exists(self.oidc_token): + raise exceptions.RestException("Cannot find oidc token.") + if not self.vo: + raise exceptions.RestException("vo is not defined for oidc authentication.") else: logging.error("auth_type %s is not supported." % str(self.auth_type)) @@ -150,10 +154,13 @@ def get_request_response(self, url, type='GET', data=None, headers=None, auth_se else: return else: - token = OIDCAuthenticationUtils.load_token(self.oidc_token) - is_expired = OIDCAuthenticationUtils.is_token_expired(token) + oidc_utils = OIDCAuthenticationUtils() + status, token = oidc_utils.load_token(self.oidc_token) + if not status: + raise exceptions.IDDSException("Token %s cannot be loaded: %s" % (self.oidc_token, str(token))) + is_expired, errors = oidc_utils.is_token_expired(token) if is_expired: - raise exceptions.IDDSException("Token is already expired.") + raise exceptions.IDDSException("Token is already expired: %s" % errors) headers['X-IDDS-Auth-Token'] = token['id_token'] if type == 'GET': @@ -173,19 +180,35 @@ def get_request_response(self, url, type='GET', data=None, headers=None, auth_se if result is not None: # print(result.text) - if result.status_code in [HTTP_STATUS_CODE.BadRequest, - HTTP_STATUS_CODE.Unauthorized, - HTTP_STATUS_CODE.Forbidden, - HTTP_STATUS_CODE.NotFound, - HTTP_STATUS_CODE.NoMethod, - HTTP_STATUS_CODE.InternalError]: - raise exceptions.IDDSException(result.text) - elif result.status_code == HTTP_STATUS_CODE.OK: + # print(result.headers) + # print(result.status_code) + if result.status_code == HTTP_STATUS_CODE.OK: # print(result.text) if result.text: return json_loads(result.text) else: return None + elif result.headers and 'ExceptionClass' in result.headers: + try: + if result.headers and 'ExceptionClass' in result.headers: + cls = getattr(exceptions, result.headers['ExceptionClass']) + msg = result.headers['ExceptionMessage'] + raise cls(msg) + else: + if result.text: + data = json_loads(result.text) + raise exceptions.IDDSException(**data) + else: + raise exceptions.IDDSException("Unknow exception: %s" % (result.text)) + except AttributeError: + raise exceptions.IDDSException(result.text) + elif result.status_code in [HTTP_STATUS_CODE.BadRequest, + HTTP_STATUS_CODE.Unauthorized, + HTTP_STATUS_CODE.Forbidden, + HTTP_STATUS_CODE.NotFound, + HTTP_STATUS_CODE.NoMethod, + HTTP_STATUS_CODE.InternalError]: + raise exceptions.IDDSException(result.text) else: try: if result.headers and 'ExceptionClass' in result.headers: diff --git a/common/lib/idds/common/authentication.py b/common/lib/idds/common/authentication.py index 8889d77d..3c18e258 100644 --- a/common/lib/idds/common/authentication.py +++ b/common/lib/idds/common/authentication.py @@ -393,6 +393,6 @@ def authenticate_oidc(vo, token): oidc_auth = OIDCAuthentication() status, data = oidc_auth.verify_id_token(vo, token) if status: - return status, None + return status, data else: return status, data From 515f7738b0e8238c35d79a86710f916bcc3c24e2 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 20:10:07 +0100 Subject: [PATCH 40/57] fix clientmanager and add ping function --- client/lib/idds/client/clientmanager.py | 111 +++++++++++++++++------- 1 file changed, 82 insertions(+), 29 deletions(-) diff --git a/client/lib/idds/client/clientmanager.py b/client/lib/idds/client/clientmanager.py index 5f40738c..6c151c94 100644 --- a/client/lib/idds/client/clientmanager.py +++ b/client/lib/idds/client/clientmanager.py @@ -31,11 +31,12 @@ raw_input = input -from idds.common.authentication import OIDCAuthenticationUtil +from idds.common.authentication import OIDCAuthenticationUtils from idds.common.utils import setup_logging, get_proxy_path from idds.client.version import release_version from idds.client.client import Client +from idds.common import exceptions from idds.common.config import get_local_cfg_file, get_local_config_root, get_local_config_value from idds.common.constants import RequestType, RequestStatus # from idds.common.utils import get_rest_host, exception_handler @@ -50,7 +51,7 @@ class ClientManager: - def __init__(self, host=None, timeout=600): + def __init__(self, host=None, timeout=600, setup_client=False): self.host = host self.timeout = timeout # if self.host is None: @@ -66,19 +67,29 @@ def __init__(self, host=None, timeout=600): self.vo = None self.configuration = ConfigParser.SafeConfigParser() - self.setup_local_configuration(host=host) + + self.client = None + # if setup_client: + # self.setup_client() + + def setup_client(self, auth_setup=False): + self.setup_local_configuration(host=self.host) if self.host is None: local_cfg = self.get_local_cfg_file() + if self.auth_type is None: + self.auth_type = 'x509_proxy' self.host = self.get_config_value(local_cfg, self.auth_type, 'host', current=self.host, default=None) if self.host is None: self.host = self.get_config_value(local_cfg, 'rest', 'host', current=self.host, default=None) - self.client = Client(host=self.host, - auth={'auth_type': self.auth_type, - 'client_proxy': self.x509_proxy, - 'oidc_token': self.oidc_token, - 'vo': self.vo}, - timeout=self.timeout) + if self.client is None: + self.client = Client(host=self.host, + auth={'auth_type': self.auth_type, + 'client_proxy': self.x509_proxy, + 'oidc_token': self.oidc_token, + 'vo': self.vo, + 'auth_setup': auth_setup}, + timeout=self.timeout) def get_local_config_root(self): local_cfg_root = get_local_config_root(self.local_config_root) @@ -127,7 +138,6 @@ def save_local_configuration(self): with open(local_cfg, 'w') as configfile: self.configuration.write(configfile) - @exception_handler def setup_local_configuration(self, local_config_root=None, config=None, host=None, auth_type=None, auth_type_host=None, x509_proxy=None, oidc_token=None, vo=None): @@ -151,11 +161,12 @@ def setup_local_configuration(self, local_config_root=None, config=None, host=No self.get_local_configuration() self.save_local_configuration() - @exception_handler def setup_oidc_token(self): """" Setup oidc token """ + self.setup_client(auth_setup=True) + sign_url = self.client.get_oidc_sign_url(self.vo) logging.info(("Please go to {0} and sign in. " "Waiting until authentication is completed").format(sign_url['verification_uri_complete'])) @@ -181,21 +192,20 @@ def setup_oidc_token(self): expires_in = 60 token = None + count = 0 start_time = datetime.datetime.utcnow() while datetime.datetime.utcnow() - start_time < datetime.timedelta(seconds=expires_in): try: - status, output = self.client.get_id_token(self.vo, sign_url['device_code']) - logging.debug("get_id_token: status: %s, output: %s" % (status, output)) - if status: - token = output - break - else: - if type(output) in [dict] and 'error' in output and output['error'] == 'authorization_pending': - logging.debug("get_id_token: pending: %s" % str(output)) - time.sleep(interval) - else: - logging.error("get_id_token: unknown error: %s" % str(output)) - break + output = self.client.get_id_token(self.vo, sign_url['device_code']) + logging.debug("get_id_token: %s" % (output)) + token = output + break + except exceptions.AuthenticationPending as error: + logging.debug("Authentication pending: %s" % str(error)) + time.sleep(interval) + count += 1 + # if count % 5 == 0: + logging.info("Authentication is still pending. Please follow the link to authorize it.") except Exception as error: logging.error("get_id_token: exception: %s" % str(error)) break @@ -203,19 +213,20 @@ def setup_oidc_token(self): if not token: logging.error("Failed to get token.") else: - oidc_util = OIDCAuthenticationUtil() + oidc_util = OIDCAuthenticationUtils() status, output = oidc_util.save_token(self.oidc_token, token) if status: logging.info("Token is saved to %s" % (self.oidc_token)) else: logging.info("Failed to save token to %s: (status: %s, output: %s)" % (self.oidc_token, status, output)) - @exception_handler def refresh_oidc_token(self): """" refresh oidc token """ - oidc_util = OIDCAuthenticationUtil() + self.setup_client(auth_setup=True) + + oidc_util = OIDCAuthenticationUtils() status, token = oidc_util.load_token(self.oidc_token) if not status: logging.error("Token %s cannot be loaded: %s" % (status, token)) @@ -237,7 +248,9 @@ def clean_oidc_token(self): """" Clean oidc token """ - oidc_util = OIDCAuthenticationUtil() + self.setup_client(auth_setup=True) + + oidc_util = OIDCAuthenticationUtils() status, output = oidc_util.clean_token(self.oidc_token) if status: logging.info("Token %s is cleaned" % self.oidc_token) @@ -249,10 +262,12 @@ def check_oidc_token_status(self): """" Check oidc token status """ - oidc_util = OIDCAuthenticationUtil() + self.setup_client(auth_setup=True) + + oidc_util = OIDCAuthenticationUtils() status, token = oidc_util.load_token(self.oidc_token) if not status: - logging.error("Token %s cannot be loaded: %s" % (status, token)) + logging.error("Token %s cannot be loaded: status: %s, error: %s" % (self.oidc_token, status, token)) return status, token_info = oidc_util.get_token_info(token) @@ -263,6 +278,16 @@ def check_oidc_token_status(self): else: logging.error("Failed to parse token information: %s" % str(token_info)) + @exception_handler + def ping(self): + """ + Ping the idds server + """ + self.setup_client() + status = self.client.ping() + # logging.info("Ping idds server: %s" % str(status)) + return status + @exception_handler def submit(self, workflow, username=None, userdn=None, use_dataset_name=True): """ @@ -270,6 +295,8 @@ def submit(self, workflow, username=None, userdn=None, use_dataset_name=True): :param workflow: The workflow to be submitted. """ + self.setup_client() + props = { 'scope': 'workflow', 'name': workflow.name, @@ -308,6 +335,8 @@ def abort(self, request_id=None, workload_id=None): :param workload_id: the workload id. :param request_id: the request. """ + self.setup_client() + if request_id is None and workload_id is None: logging.error("Both request_id and workload_id are None. One of them should not be None") return (-1, "Both request_id and workload_id are None. One of them should not be None") @@ -334,6 +363,8 @@ def suspend(self, request_id=None, workload_id=None): :param workload_id: the workload id. :param request_id: the request. """ + self.setup_client() + if request_id is None and workload_id is None: logging.error("Both request_id and workload_id are None. One of them should not be None") return (-1, "Both request_id and workload_id are None. One of them should not be None") @@ -360,6 +391,8 @@ def resume(self, request_id=None, workload_id=None): :param workload_id: the workload id. :param request_id: the request. """ + self.setup_client() + if request_id is None and workload_id is None: logging.error("Both request_id and workload_id are None. One of them should not be None") return (-1, "Both request_id and workload_id are None. One of them should not be None") @@ -386,6 +419,8 @@ def retry(self, request_id=None, workload_id=None): :param workload_id: the workload id. :param request_id: the request. """ + self.setup_client() + if request_id is None and workload_id is None: logging.error("Both request_id and workload_id are None. One of them should not be None") return (-1, "Both request_id and workload_id are None. One of them should not be None") @@ -412,6 +447,8 @@ def finish(self, request_id=None, workload_id=None, set_all_finished=False): :param workload_id: the workload id. :param request_id: the request. """ + self.setup_client() + if request_id is None and workload_id is None: logging.error("Both request_id and workload_id are None. One of them should not be None") return (-1, "Both request_id and workload_id are None. One of them should not be None") @@ -443,6 +480,8 @@ def get_requests(self, request_id=None, workload_id=None, with_detail=False, wit :param request_id: the request. :param with_detail: Whether to show detail info. """ + self.setup_client() + reqs = self.client.get_requests(request_id=request_id, workload_id=workload_id, with_detail=with_detail, with_metadata=with_metadata) return reqs @@ -455,6 +494,8 @@ def get_status(self, request_id=None, workload_id=None, with_detail=False, with_ :param request_id: the request. :param with_detail: Whether to show detail info. """ + self.setup_client() + reqs = self.client.get_requests(request_id=request_id, workload_id=workload_id, with_detail=with_detail, with_metadata=with_metadata) if with_detail: table = [] @@ -483,6 +524,8 @@ def download_logs(self, request_id=None, workload_id=None, dest_dir='./', filena :param dest_dir: The destination directory. :param filename: The destination filename to be saved. If it's None, default filename will be saved. """ + self.setup_client() + filename = self.client.download_logs(request_id=request_id, workload_id=workload_id, dest_dir=dest_dir, filename=filename) if filename: logging.info("Logs are downloaded to %s" % filename) @@ -496,6 +539,8 @@ def upload_to_cacher(self, filename): """ Upload file to iDDS cacher: On the cacher, the filename will be the basename of the file. """ + self.setup_client() + return self.client.upload(filename) @exception_handler @@ -503,6 +548,8 @@ def download_from_cacher(self, filename): """ Download file from iDDS cacher: On the cacher, the filename will be the basename of the file. """ + self.setup_client() + return self.client.download(filename) @exception_handler @@ -517,6 +564,8 @@ def get_hyperparameters(self, workload_id, request_id, id=None, status=None, lim :raise exceptions if it's not got successfully. """ + self.setup_client() + return self.client.get_hyperparameters(workload_id=workload_id, request_id=request_id, id=id, status=status, limit=limit) @exception_handler @@ -531,6 +580,8 @@ def update_hyperparameter(self, workload_id, request_id, id, loss): :raise exceptions if it's not updated successfully. """ + self.setup_client() + return self.client.update_hyperparameter(workload_id=workload_id, request_id=request_id, id=id, loss=loss) @exception_handler @@ -541,6 +592,8 @@ def get_messages(self, request_id=None, workload_id=None): :param workload_id: the workload id. :param request_id: the request. """ + self.setup_client() + if request_id is None and workload_id is None: logging.error("Both request_id and workload_id are None. One of them should not be None") return (-1, "Both request_id and workload_id are None. One of them should not be None") From facc57244bf560254b022234fb206c1ec94a79c6 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 20:10:44 +0100 Subject: [PATCH 41/57] add ping command --- client/bin/idds | 57 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/client/bin/idds b/client/bin/idds index 2d54bc23..9b0fac14 100755 --- a/client/bin/idds +++ b/client/bin/idds @@ -21,13 +21,14 @@ import logging import os import sys import time +# import traceback from idds.client.version import release_version from idds.client.clientmanager import ClientManager def setup(args): - cm = ClientManager(host=args.host) + cm = ClientManager(host=args.host, setup_client=False) cm.setup_local_configuration(local_config_root=args.local_config_root, config=args.config, host=args.host, auth_type=args.auth_type, @@ -39,68 +40,79 @@ def setup(args): def setup_oidc_token(args): - cm = ClientManager(host=args.host) + cm = ClientManager(host=args.host, setup_client=True) cm.setup_oidc_token() def clean_oidc_token(args): - cm = ClientManager(host=args.host) + cm = ClientManager(host=args.host, setup_client=False) cm.clean_oidc_token() def check_oidc_token_status(args): - cm = ClientManager(host=args.host) + cm = ClientManager(host=args.host, setup_client=False) cm.check_oidc_token_status() +def refresh_oidc_token(args): + cm = ClientManager(host=args.host, setup_client=False) + cm.refresh_oidc_token() + + +def ping(args): + cm = ClientManager(host=args.host, setup_client=False) + status = cm.ping() + print(status) + + def get_requests_status(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) ret = wm.get_status(request_id=args.request_id, workload_id=args.workload_id, with_detail=args.with_detail) print(ret) def abort_requests(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.abort(request_id=args.request_id, workload_id=args.workload_id) def suspend_requests(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.suspend(request_id=args.request_id, workload_id=args.workload_id) def resume_requests(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.resume(request_id=args.request_id, workload_id=args.workload_id) def retry_requests(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.retry(request_id=args.request_id, workload_id=args.workload_id) def finish_requests(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.finish(request_id=args.request_id, workload_id=args.workload_id, set_all_finished=args.set_all_finished) def download_logs(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.download_logs(request_id=args.request_id, workload_id=args.workload_id, dest_dir=args.dest_dir, filename=args.dest_filename) def upload_to_cacher(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.upload_to_cacher(args.filename) def download_from_cacher(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) wm.download_from_cacher(args.filename) def get_hyperparameters(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) ret = wm.get_hyperparameters(workload_id=args.workload_id, request_id=args.request_id, id=args.id, status=args.status, limit=args.limit) # print(json.dumps(ret, sort_keys=True, indent=4)) for k in ret: @@ -108,13 +120,13 @@ def get_hyperparameters(args): def update_hyperparameter(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) ret = wm.update_hyperparameter(workload_id=args.workload_id, request_id=args.request_id, id=args.id, loss=args.loss) print(ret) def get_messages(args): - wm = ClientManager(host=args.host) + wm = ClientManager(host=args.host, setup_client=True) ret = wm.get_messages(request_id=args.request_id, workload_id=args.workload_id) status, msgs = ret print("status: %s" % status) @@ -139,7 +151,7 @@ def get_parser(): setup_parser = subparsers.add_parser('setup', help='Setup local configuration') setup_parser.set_defaults(function=setup) setup_parser.add_argument('--host', dest="auth_type_host", metavar="ADDRESS", help="The iDDS Rest host for the current auth type. For example: https://hostname:443/idds") - setup_parser.add_argument('--auth_type', dest='auth_type', action='store', default=None, help='The auth_type in [x509_proxy, oidc]. Default is x509_proxy.') + setup_parser.add_argument('--auth_type', dest='auth_type', action='store', choices=['x509_proxy', 'oidc'], default=None, help='The auth_type in [x509_proxy, oidc]. Default is x509_proxy.') setup_parser.add_argument('--x509_proxy', dest='x509_proxy', action='store', default=None, help='The x509 proxy path. Default is /tmp/x509up_u%d.' % os.geteuid()) setup_parser.add_argument('--vo', dest='vo', action='store', default=None, help='The virtual organization for authentication.') setup_parser.add_argument('--oidc_token', dest='oidc_token', default=None, help='The oidc token path. Default is {local_config_root}/.oidc_token.') @@ -164,9 +176,17 @@ def get_parser(): token_clean_parser.set_defaults(function=clean_oidc_token) # check token status - token_check_parser = subparsers.add_parser('oidc_token_info', help='Check authentication token information') + token_check_parser = subparsers.add_parser('get_oidc_token_info', help='Check authentication token information') token_check_parser.set_defaults(function=check_oidc_token_status) + # refresh token + token_refresh_parser = subparsers.add_parser('refresh_oidc_token', help='Refresh authentication token') + token_refresh_parser.set_defaults(function=refresh_oidc_token) + + # ping + ping_parser = subparsers.add_parser('ping', help='Ping idds server') + ping_parser.set_defaults(function=ping) + # get request status req_status_parser = subparsers.add_parser('get_requests_status', help='Get the requests status') req_status_parser.set_defaults(function=get_requests_status) @@ -277,4 +297,5 @@ if __name__ == '__main__': sys.exit(0) except Exception as error: logging.error("Strange error: {0}".format(error)) + # logging.error(traceback.format_exc()) sys.exit(-1) From 4f967587d0ac67ef306fe1bacbe674d0996f67f0 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 20:13:04 +0100 Subject: [PATCH 42/57] fix auth rest --- main/lib/idds/rest/v1/auth.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/main/lib/idds/rest/v1/auth.py b/main/lib/idds/rest/v1/auth.py index e3c510ab..88f9a7d6 100644 --- a/main/lib/idds/rest/v1/auth.py +++ b/main/lib/idds/rest/v1/auth.py @@ -35,9 +35,12 @@ def get(self, vo, auth_type='oidc'): try: if auth_type == 'oidc': oidc = OIDCAuthentication() - sign_url = oidc.get_oidc_sign_url() - rets = sign_url - return self.generate_http_response(HTTP_STATUS_CODE.OK, data=rets) + status, sign_url = oidc.get_oidc_sign_url(vo) + if status: + rets = sign_url + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=rets) + else: + raise exceptions.IDDSException("Failed to get oidc sign url: %s" % str(sign_url)) else: raise exceptions.NotSupportedAuthentication("auth_type %s is not supported." % str(auth_type)) except exceptions.NoObject as error: @@ -65,8 +68,14 @@ def get(self, vo, device_code, interval=5, expires_in=60): try: oidc = OIDCAuthentication() - id_token = oidc.get_id_token(vo, device_code, interval, expires_in) - return self.generate_http_response(HTTP_STATUS_CODE.OK, data=id_token) + status, id_token = oidc.get_id_token(vo, device_code, interval, expires_in) + if status: + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=id_token) + else: + if 'error' in id_token and 'authorization_pending' in id_token['error']: + raise exceptions.AuthenticationPending(str(id_token)) + else: + raise exceptions.IDDSException("Failed to get oidc token: %s" % str(id_token)) except exceptions.NoObject as error: return self.generate_http_response(HTTP_STATUS_CODE.NotFound, exc_cls=error.__class__.__name__, exc_msg=error) except exceptions.IDDSException as error: @@ -89,8 +98,11 @@ def post(self, vo): refresh_token = parameters['refresh_token'] oidc = OIDCAuthentication() - id_token = oidc.refresh_id_token(refresh_token) - return self.generate_http_response(HTTP_STATUS_CODE.OK, data=id_token) + status, id_token = oidc.refresh_id_token(vo, refresh_token) + if status: + return self.generate_http_response(HTTP_STATUS_CODE.OK, data=id_token) + else: + raise exceptions.IDDSException("Failed to refresh oidc token: %s" % str(id_token)) except exceptions.NoObject as error: return self.generate_http_response(HTTP_STATUS_CODE.NotFound, exc_cls=error.__class__.__name__, exc_msg=error) except exceptions.IDDSException as error: @@ -113,7 +125,7 @@ def post_test(self): def get_blueprint(): - bp = Blueprint('message', __name__) + bp = Blueprint('auth', __name__) url_view = OIDCAuthenticationSignURL.as_view('url') bp.add_url_rule('/auth/url/', view_func=url_view, methods=['get']) From 7267a868d93116a824e3e523ec1682fe5663a614 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 20:34:03 +0100 Subject: [PATCH 43/57] add new AuthenticationPending exception --- common/lib/idds/common/exceptions.py | 20 ++++++++++++++++++++ common/lib/idds/common/utils.py | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/common/lib/idds/common/exceptions.py b/common/lib/idds/common/exceptions.py index cc0a09fa..9fab3d8a 100644 --- a/common/lib/idds/common/exceptions.py +++ b/common/lib/idds/common/exceptions.py @@ -232,3 +232,23 @@ def __init__(self, *args, **kwargs): super(AgentPluginError, self).__init__(*args, **kwargs) self._message = "Agent plugin exception." self.error_code = 502 + + +class AuthenticationException(IDDSException): + """ + AuthenticationException + """ + def __init__(self, *args, **kwargs): + super(AuthenticationException, self).__init__(*args, **kwargs) + self._message = "Authentication exception." + self.error_code = 600 + + +class AuthenticationPending(IDDSException): + """ + Authentication pending + """ + def __init__(self, *args, **kwargs): + super(AuthenticationPending, self).__init__(*args, **kwargs) + self._message = "Authentication pending." + self.error_code = 601 diff --git a/common/lib/idds/common/utils.py b/common/lib/idds/common/utils.py index a52981e9..bc0efe18 100644 --- a/common/lib/idds/common/utils.py +++ b/common/lib/idds/common/utils.py @@ -405,11 +405,11 @@ def new_funct(*args, **kwargs): return function(*args, **kwargs) except IDDSException as ex: logging.error(ex) - print(traceback.format_exc()) + # print(traceback.format_exc()) return str(ex) except Exception as ex: logging.error(ex) - print(traceback.format_exc()) + # print(traceback.format_exc()) return str(ex) return new_funct From 1f5e05e379bc67ff37075e6fda1f6e5deb03b021 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 20:35:06 +0100 Subject: [PATCH 44/57] increase the pending time --- main/lib/idds/tests/test_domapanda.py | 2 +- main/lib/idds/tests/test_domapanda_pandaclient.py | 2 +- main/lib/idds/tests/test_domapanda_workflow.py | 2 +- monitor/conf.js | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/main/lib/idds/tests/test_domapanda.py b/main/lib/idds/tests/test_domapanda.py index 8670888a..04a90bf1 100644 --- a/main/lib/idds/tests/test_domapanda.py +++ b/main/lib/idds/tests/test_domapanda.py @@ -160,7 +160,7 @@ def setup_workflow(): "value": "log.tgz"}, task_cloud='LSST') - pending_time = 0.5 + pending_time = 12 # pending_time = None workflow = Workflow(pending_time=pending_time) workflow.add_work(work1) diff --git a/main/lib/idds/tests/test_domapanda_pandaclient.py b/main/lib/idds/tests/test_domapanda_pandaclient.py index b006ac6e..2ce49e78 100644 --- a/main/lib/idds/tests/test_domapanda_pandaclient.py +++ b/main/lib/idds/tests/test_domapanda_pandaclient.py @@ -163,7 +163,7 @@ def setup_workflow(): task_cloud='LSST', encode_command_line=True) - pending_time = 0.5 + pending_time = 12 # pending_time = None workflow = Workflow(pending_time=pending_time) workflow.add_work(work1) diff --git a/main/lib/idds/tests/test_domapanda_workflow.py b/main/lib/idds/tests/test_domapanda_workflow.py index 9c6d75de..48712617 100644 --- a/main/lib/idds/tests/test_domapanda_workflow.py +++ b/main/lib/idds/tests/test_domapanda_workflow.py @@ -159,7 +159,7 @@ def setup_workflow(): cond1 = Condition(cond=work1.is_finished, true_work=work2) cond2 = Condition(cond=work2.is_finished, true_work=work3) - pending_time = 0.5 + pending_time = 12 # pending_time = None workflow = Workflow(pending_time=pending_time) workflow.add_work(work1) diff --git a/monitor/conf.js b/monitor/conf.js index 9042281f..ebb2d086 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus766.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus766.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus766.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus766.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus766.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus766.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus724.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus724.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus724.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus724.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus724.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus724.cern.ch:443/idds/monitor/null/null/false/false/true" } From ba35978b8ef99dd1b27f644a7cdfd3ca6a3d90dd Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 9 Feb 2022 21:43:14 +0100 Subject: [PATCH 45/57] fix flake8 errors --- common/lib/idds/common/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/idds/common/utils.py b/common/lib/idds/common/utils.py index bc0efe18..76a0780e 100644 --- a/common/lib/idds/common/utils.py +++ b/common/lib/idds/common/utils.py @@ -18,7 +18,7 @@ import subprocess import sys import tarfile -import traceback +# import traceback from enum import Enum from functools import wraps From 74fffa817547027c84c4f942cc6bb16f3d05a22f Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Thu, 10 Feb 2022 15:28:47 +0100 Subject: [PATCH 46/57] update docs --- docs/source/domausers/cli_examples.rst | 88 +++++++++++++++++ docs/source/domausers/installing_client.rst | 38 ++++++++ docs/source/general/v2/architecture.rst | 3 +- docs/source/general/v2/authorization.rst | 35 +++++++ docs/source/images/v2/idds_authentication.jpg | Bin 0 -> 115421 bytes docs/source/images/v2/idds_structure.jpg | Bin 0 -> 154957 bytes docs/source/index.rst | 12 ++- docs/source/source_codes.rst | 7 ++ docs/source/users/cli_examples.rst | 89 +++++++++++++++++- docs/source/users/installing_client.rst | 29 +++--- 10 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 docs/source/domausers/cli_examples.rst create mode 100644 docs/source/domausers/installing_client.rst create mode 100644 docs/source/general/v2/authorization.rst create mode 100644 docs/source/images/v2/idds_authentication.jpg create mode 100644 docs/source/images/v2/idds_structure.jpg create mode 100644 docs/source/source_codes.rst diff --git a/docs/source/domausers/cli_examples.rst b/docs/source/domausers/cli_examples.rst new file mode 100644 index 00000000..501ecad7 --- /dev/null +++ b/docs/source/domausers/cli_examples.rst @@ -0,0 +1,88 @@ +iDDS OIDC authorization +============================= + +Here are the commands how to setup oidc tokens. For other client examples, please check normal user documents. + +iDDS OIDC authorization +~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Setup the client. It's for users to setup the client for the first time or update the client configurations. +By default it will create a file in ~/.idds/idds_local.cfg to remember these configurations. + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.setup_local_configuration(local_config_root=, # default ~/.idds/ + host=, # default host for different authorization methods. https://:443/idds + auth_type=, # authorization type: x509_proxy, oidc + auth_type_host=, # for different authorization methods, users can define different idds servers. + x509_proxy=, + vo=, + +2. setup oidc token + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.setup_oidc_token() + +3. refresh oidc token + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.refresh_oidc_token() + +4. get token info + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.check_oidc_token_status() + +5. clean oidc token + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.clean_oidc_token() + + +iDDS OIDC Command Line Interface (CLI) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Setup the client. It's for users to setup the client for the first time or update the client configurations. +By default it will create a file in ~/.idds/idds_local.cfg to remember these configurations. + +.. code-block:: python + + idds setup --auth_type oidc --host https://:443/idds --vo Rubin + +2. setup oidc token + +.. code-block:: python + + idds setup_oidc_token + +3. refresh oidc token + +.. code-block:: python + + idds refresh_oidc_token + +4. get token info + +.. code-block:: python + + idds get_oidc_token_info + +5. clean oidc token + +.. code-block:: python + + idds clean_oidc_token diff --git a/docs/source/domausers/installing_client.rst b/docs/source/domausers/installing_client.rst new file mode 100644 index 00000000..532128b5 --- /dev/null +++ b/docs/source/domausers/installing_client.rst @@ -0,0 +1,38 @@ +Installing iDDS Clients +======================= + +Prerequisites +~~~~~~~~~~~~~~ + +iDDS clients run on Python 2.7, 3.6 on any Unix-like platform. + + +Python Dependencies +~~~~~~~~~~~~~~~~~~~~ + +All Dependencies are automatically installed with pip. + +Install via pip +~~~~~~~~~~~~~~~ + +When ``pip`` is available, the distribution can be downloaded from the iDDS PyPI server and installed in one step:: + + $> pip install idds-common idds-client idds-workflow idds-doma + +This command will download the latest version of Rucio and install it to your system. + + +Upgrade via pip +~~~~~~~~~~~~~~~~ + +To upgrade via pip:: + + $> pip install --upgrade idds-common idds-client idds-workflow idds-doma + + +config client +~~~~~~~~~~~~~ + +To use iDDS client to access the iDDS server, a config file is needed. Below is an example of the config file:: + + $> idds setup --auth_type oidc --host https://:443/idds --vo Rubin diff --git a/docs/source/general/v2/architecture.rst b/docs/source/general/v2/architecture.rst index 79536e2a..c47f193b 100644 --- a/docs/source/general/v2/architecture.rst +++ b/docs/source/general/v2/architecture.rst @@ -4,7 +4,8 @@ Architecture The iDDS is implemented in a distributed architecture. It composed of Daemons Agents, RESTful serivces, User Interface and External Plugins. -.. image:: ../../images/v2/architecture_daemon_flow.png + +.. image:: ../../images/v2/idds_architecture.jpg :alt: iDDS Architecture Layers diff --git a/docs/source/general/v2/authorization.rst b/docs/source/general/v2/authorization.rst new file mode 100644 index 00000000..9a35f199 --- /dev/null +++ b/docs/source/general/v2/authorization.rst @@ -0,0 +1,35 @@ +Authorization +============== + +The iDDS currently supports both x509_proxy and oidc based authroization. + + +.. image:: ../../images/v2/idds_authentication.jpg + :alt: iDDS Architecture + +509_proxy +~~~~~~~~~~ + +x509_proxy based authorization is the default authorization method for iDDS. +It's implemented mainly based mod_ssl and mod_gridsite. + +oidc +~~~~~~~~ + +.. image:: ../../images/v2/idds_authentication.jpg + :alt: iDDS OIDC authorization + +The iDDS OIDC authorization is based on the IAM service. Here are the steps for token initialization: + +1. Get sign url: Get a sign url with a device code for users to approve. +2. User goes to the IAM service and approves the token request with the sign url. +3. Get the token with the device code. +4. The iDDS OIDC authorization service also includes services such as token refresh, token clean, token information checks and so on. + + +For normal iDDS requests, here are steps how iDDS authorize a users. + +1. User initializes a normal request. +2. iDDS automatically finds the token and loads the token to headers of the http request. +3. Send the request to iDDS REST server. +4. iDDS server parse the token and verify the token against the IAM server. Verified users will be authorized. diff --git a/docs/source/images/v2/idds_authentication.jpg b/docs/source/images/v2/idds_authentication.jpg new file mode 100644 index 0000000000000000000000000000000000000000..53384ed51bc9ad76e68d78420e96f8ce21d9ac8c GIT binary patch literal 115421 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/v2/idds_structure.jpg b/docs/source/images/v2/idds_structure.jpg new file mode 100644 index 0000000000000000000000000000000000000000..981d77f3411f711ddb418cc57fbb3ff102585ebb GIT binary patch literal 154957 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

Q55GLhe`WTG~hX&QLu}2}r8IKaK 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 TNV?{Zmip_F;Q#dweH-~FdLaF5 literal 0 HcmV?d00001 diff --git a/docs/source/index.rst b/docs/source/index.rst index 39d315ef..f0e84323 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -32,6 +32,7 @@ For documentation specific to any of these three, please see the subsequent sect general/v2/architecture general/v2/workflow general/v2/dag + general/v2/authorization Use Cases ========= @@ -60,13 +61,22 @@ User Documentation users/admin_guides users/contributing +DOMA User Documentation +========================= + +.. toctree:: + :maxdepth: 1 + + domausers/installing_client + domausers/cli_examples + Source Codes ============= .. toctree:: :maxdepth: 1 - codes/libraries + source_codes Indices and tables ================== diff --git a/docs/source/source_codes.rst b/docs/source/source_codes.rst new file mode 100644 index 00000000..44426e87 --- /dev/null +++ b/docs/source/source_codes.rst @@ -0,0 +1,7 @@ +iDDS Source Codes +============================= + +.. toctree:: + :maxdepth: 1 + + codes/libraries diff --git a/docs/source/users/cli_examples.rst b/docs/source/users/cli_examples.rst index c8ae1066..8ccaadfd 100644 --- a/docs/source/users/cli_examples.rst +++ b/docs/source/users/cli_examples.rst @@ -3,6 +3,91 @@ iDDS RESTful client: Examples iDDS provides RESTful services and the client is used to access the RESTful service. +iDDS OIDC authorization +~~~~~~~~~~~~~~~~~~~~~~~ + +1. Setup the client. It's for users to setup the client for the first time or update the client configurations. +By default it will create a file in ~/.idds/idds_local.cfg to remember these configurations. + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.setup_local_configuration(local_config_root=, # default ~/.idds/ + host=, # default host for different authorization methods. https://:443/idds + auth_type=, # authorization type: x509_proxy, oidc + auth_type_host=, # for different authorization methods, users can define different idds servers. + x509_proxy=, + vo=, + +2. setup oidc token + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.setup_oidc_token() + +3. refresh oidc token + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.refresh_oidc_token() + +4. get token info + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.check_oidc_token_status() + +5. clean oidc token + +.. code-block:: python + + from idds.client.clientmanager import ClientManager + cm = ClientManager() + cm.clean_oidc_token() + + +iDDS OIDC Command Line Interface (CLI) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Setup the client. It's for users to setup the client for the first time or update the client configurations. +By default it will create a file in ~/.idds/idds_local.cfg to remember these configurations. + +.. code-block:: python + + idds setup --auth_type oidc --host https://:443/idds --vo Rubin + +2. setup oidc token + +.. code-block:: python + + idds setup_oidc_token + +3. refresh oidc token + +.. code-block:: python + + idds refresh_oidc_token + +4. get token info + +.. code-block:: python + + idds get_oidc_token_info + +5. clean oidc token + +.. code-block:: python + + idds clean_oidc_token + + iDDS workflow manager ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -22,7 +107,7 @@ Below is one example for submitting a workflow. # get a workflow workflow = get_workflow() - cm = ClientManager(host=host) + cm = ClientManager(host=host) # here the host will overwrite the host defined in the configurations. request_id = cm.submit(workflow) Below is an example for data carousel @@ -175,7 +260,7 @@ Below is an example for hyperparameter optimization from idds.client.clientmanager import ClientManager host = 'https://iddsserver.cern.ch:443/idds' - clientmanager = ClientManager(host=host) + clientmanager = ClientManager(host=host) # here the host will overwirte the host defined in the configurations. # clientmanager = ClientManager() # if idds.cfg is configured with [rest] host. diff --git a/docs/source/users/installing_client.rst b/docs/source/users/installing_client.rst index f77bdf12..0897dc2e 100644 --- a/docs/source/users/installing_client.rst +++ b/docs/source/users/installing_client.rst @@ -30,24 +30,25 @@ To upgrade via pip:: $> pip install --upgrade idds-common idds-client -config client -~~~~~~~~~~~~~ +ATLAS Users +~~~~~~~~~~~~~~~ -To use iDDS client to access the iDDS server, a config file is needed. Below is an example of the config file. +To install via pip:: -.. code-block:: python + $> pip install --upgrade idds-common idds-client idds-workflow idds-atlas - [common] - loglevel = INFO - [rest] - host = https://:/idds +DOMA Users +~~~~~~~~~~~~~~ + +To install via pip:: -iDDS will look for this config file in order of: + $> pip install --upgrade idds-common idds-client idds-workflow idds-doma + + +config client +~~~~~~~~~~~~~ -.. code-block:: python +To use iDDS client to access the iDDS server, a config file is needed. Below is an example of the config file:: - ${IDDS_CONFIG} - ${IDDS_HOME}/etc/idds/idds.cfg - /etc/idds/idds.cfg - ${VIRTUAL_ENV}/etc/idds/idds.cfg + $> idds setup --auth_type oidc --host https://:443/idds --vo Rubin From b7d361ff05a899b582805c9947027698da94ec34 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Thu, 10 Feb 2022 15:36:06 +0100 Subject: [PATCH 47/57] update docs --- docs/source/general/v2/architecture.rst | 1 - docs/source/general/v2/authorization.rst | 4 ---- docs/source/index.rst | 2 +- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/source/general/v2/architecture.rst b/docs/source/general/v2/architecture.rst index c47f193b..e39a73e7 100644 --- a/docs/source/general/v2/architecture.rst +++ b/docs/source/general/v2/architecture.rst @@ -4,7 +4,6 @@ Architecture The iDDS is implemented in a distributed architecture. It composed of Daemons Agents, RESTful serivces, User Interface and External Plugins. - .. image:: ../../images/v2/idds_architecture.jpg :alt: iDDS Architecture diff --git a/docs/source/general/v2/authorization.rst b/docs/source/general/v2/authorization.rst index 9a35f199..bfb3b488 100644 --- a/docs/source/general/v2/authorization.rst +++ b/docs/source/general/v2/authorization.rst @@ -3,10 +3,6 @@ Authorization The iDDS currently supports both x509_proxy and oidc based authroization. - -.. image:: ../../images/v2/idds_authentication.jpg - :alt: iDDS Architecture - 509_proxy ~~~~~~~~~~ diff --git a/docs/source/index.rst b/docs/source/index.rst index f0e84323..68a5aebe 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -74,7 +74,7 @@ Source Codes ============= .. toctree:: - :maxdepth: 1 + :maxdepth: 0 source_codes From a0260a043157bbc22599d5ca10bb7bc1edf4e423 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Thu, 10 Feb 2022 15:42:35 +0100 Subject: [PATCH 48/57] update docs --- docs/source/general/v2/architecture.rst | 2 +- docs/source/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/general/v2/architecture.rst b/docs/source/general/v2/architecture.rst index e39a73e7..204ad814 100644 --- a/docs/source/general/v2/architecture.rst +++ b/docs/source/general/v2/architecture.rst @@ -4,7 +4,7 @@ Architecture The iDDS is implemented in a distributed architecture. It composed of Daemons Agents, RESTful serivces, User Interface and External Plugins. -.. image:: ../../images/v2/idds_architecture.jpg +.. image:: ../../images/v2/idds_structure.jpg :alt: iDDS Architecture Layers diff --git a/docs/source/index.rst b/docs/source/index.rst index 68a5aebe..c7ee405d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -74,7 +74,7 @@ Source Codes ============= .. toctree:: - :maxdepth: 0 + :maxdepth: 1 source_codes From cfe8df5b3e5b66c1f5b001292ba565537cc7850a Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 16 Feb 2022 15:33:28 +0100 Subject: [PATCH 49/57] new version 0.10.0 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- common/tools/env/environment.yml | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 14 files changed, 19 insertions(+), 19 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index 1306461e..14b973e1 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index b972abb5..7bd5269f 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.9.5 - - idds-workflow==0.9.5 \ No newline at end of file + - idds-common==0.10.0 + - idds-workflow==0.10.0 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index 1306461e..14b973e1 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index 287fa075..85de9e0f 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.9.5 - - idds-workflow==0.9.5 \ No newline at end of file + - idds-common==0.10.0 + - idds-workflow==0.10.0 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index 1306461e..14b973e1 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/common/tools/env/environment.yml b/common/tools/env/environment.yml index 41e3db72..26017dc8 100644 --- a/common/tools/env/environment.yml +++ b/common/tools/env/environment.yml @@ -9,4 +9,4 @@ dependencies: - pep8 # checks for PEP8 code style compliance - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - - nose # nose test tools + - nose # nose test tools \ No newline at end of file diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 3af3b875..944d01c9 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index 0bda9433..6826fa97 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.9.5 - - idds-workflow==0.9.5 \ No newline at end of file + - idds-common==0.10.0 + - idds-workflow==0.10.0 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index 1306461e..14b973e1 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 4bd785a0..3041d19a 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -24,6 +24,6 @@ dependencies: - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - psycopg2-binary - - idds-common==0.9.5 - - idds-workflow==0.9.5 - - idds-client==0.9.5 + - idds-common==0.10.0 + - idds-workflow==0.10.0 + - idds-client==0.10.0 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index 1306461e..14b973e1 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/website/version.py b/website/version.py index 1306461e..14b973e1 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index 1306461e..14b973e1 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.9.5" +release_version = "0.10.0" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index 2df9a55c..fd71674f 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.9.5 \ No newline at end of file + - idds-common==0.10.0 \ No newline at end of file From 5ddc8ae00895ad94ab69419923c16fab87ca9af9 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 16 Feb 2022 16:35:11 +0100 Subject: [PATCH 50/57] improve x509 auth --- common/lib/idds/common/authentication.py | 31 +++++++++++++++++++++--- main/etc/idds/auth/auth.cfg.template | 1 + monitor/conf.js | 12 ++++----- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/common/lib/idds/common/authentication.py b/common/lib/idds/common/authentication.py index 3c18e258..de485c5a 100644 --- a/common/lib/idds/common/authentication.py +++ b/common/lib/idds/common/authentication.py @@ -336,6 +336,16 @@ def get_ban_user_list(self): return users return [] + def get_allow_user_list(self): + section = "Users" + option = "allow_users" + if self.config and self.config.has_section(section): + if self.config.has_option(option): + users = self.config.get(option) + users = users.split(",") + return users + return [] + def get_user_name_from_dn(dn): try: @@ -382,10 +392,23 @@ def authenticate_x509(vo, dn, client_cert): # print(certDecoded.issuer) # for ext in certDecoded.extensions: # print(ext) - username = get_user_name_from_dn(dn) - ban_user_list = X509Authentication().get_ban_user_list() - if username in ban_user_list: - return False, "User %s is banned" % username + allow_user_list = X509Authentication().get_allow_user_list() + matched = False + for allow_user in allow_user_list: + pat = re.compile(allow_user) + mat = pat.match(dn) + if mat: + matched = True + break + + if matched: + # username = get_user_name_from_dn(dn) + ban_user_list = X509Authentication().get_ban_user_list() + for ban_user in ban_user_list: + pat = re.compile(ban_user) + mat = pat.match(dn) + if mat: + return False, "User %s is banned" % str(dn) return True, None diff --git a/main/etc/idds/auth/auth.cfg.template b/main/etc/idds/auth/auth.cfg.template index 10e9b48f..f1a3b246 100644 --- a/main/etc/idds/auth/auth.cfg.template +++ b/main/etc/idds/auth/auth.cfg.template @@ -23,4 +23,5 @@ oidc_config_url = https://panda-iam-doma.cern.ch/.well-known/openid-configuratio vo = Rubin [Users] +allow_users = * ban_users = testuser diff --git a/monitor/conf.js b/monitor/conf.js index ebb2d086..bb3e1caa 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus724.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus724.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus724.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus724.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus724.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus724.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus7104.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus7104.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus7104.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus7104.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus7104.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus7104.cern.ch:443/idds/monitor/null/null/false/false/true" } From 726933a771e76a47dfb1e15cb959e018b45dde8b Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Wed, 16 Feb 2022 16:35:43 +0100 Subject: [PATCH 51/57] new version 0.10.1 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index 14b973e1..1ff8fb26 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index 7bd5269f..397ebb5f 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.10.0 - - idds-workflow==0.10.0 \ No newline at end of file + - idds-common==0.10.1 + - idds-workflow==0.10.1 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index 14b973e1..1ff8fb26 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index 85de9e0f..84692da6 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.10.0 - - idds-workflow==0.10.0 \ No newline at end of file + - idds-common==0.10.1 + - idds-workflow==0.10.1 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index 14b973e1..1ff8fb26 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 944d01c9..84dcff62 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index 6826fa97..b766fd5d 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.10.0 - - idds-workflow==0.10.0 \ No newline at end of file + - idds-common==0.10.1 + - idds-workflow==0.10.1 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index 14b973e1..1ff8fb26 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 3041d19a..06509d6c 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -24,6 +24,6 @@ dependencies: - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - psycopg2-binary - - idds-common==0.10.0 - - idds-workflow==0.10.0 - - idds-client==0.10.0 \ No newline at end of file + - idds-common==0.10.1 + - idds-workflow==0.10.1 + - idds-client==0.10.1 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index 14b973e1..1ff8fb26 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/website/version.py b/website/version.py index 14b973e1..1ff8fb26 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index 14b973e1..1ff8fb26 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.0" +release_version = "0.10.1" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index fd71674f..983e322d 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.10.0 \ No newline at end of file + - idds-common==0.10.1 \ No newline at end of file From 08b9f4fd7164fa4e8ade332ee67aaf84e9553ccc Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 22 Feb 2022 13:24:51 +0100 Subject: [PATCH 52/57] fix new version comparing --- common/lib/idds/common/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/idds/common/utils.py b/common/lib/idds/common/utils.py index 76a0780e..39594940 100644 --- a/common/lib/idds/common/utils.py +++ b/common/lib/idds/common/utils.py @@ -22,6 +22,7 @@ from enum import Enum from functools import wraps +from packaging import version as packaging_version from idds.common.config import (config_has_section, config_has_option, config_get, config_get_bool) @@ -452,7 +453,7 @@ def get_proxy(): def is_new_version(version1, version2): - return version1 > version2 + return packaging_version.parse(version1) > packaging_version.parse(version2) def extract_scope_atlas(did, scopes): From 597f705aeb07e4950e6c9a4de7d772d318061507 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 22 Feb 2022 13:25:18 +0100 Subject: [PATCH 53/57] fix to add allow user matches --- common/lib/idds/common/authentication.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/common/lib/idds/common/authentication.py b/common/lib/idds/common/authentication.py index de485c5a..c4488552 100644 --- a/common/lib/idds/common/authentication.py +++ b/common/lib/idds/common/authentication.py @@ -330,8 +330,8 @@ def get_ban_user_list(self): section = "Users" option = "ban_users" if self.config and self.config.has_section(section): - if self.config.has_option(option): - users = self.config.get(option) + if self.config.has_option(section, option): + users = self.config.get(section, option) users = users.split(",") return users return [] @@ -340,8 +340,8 @@ def get_allow_user_list(self): section = "Users" option = "allow_users" if self.config and self.config.has_section(section): - if self.config.has_option(option): - users = self.config.get(option) + if self.config.has_option(section, option): + users = self.config.get(section, option) users = users.split(",") return users return [] @@ -395,12 +395,16 @@ def authenticate_x509(vo, dn, client_cert): allow_user_list = X509Authentication().get_allow_user_list() matched = False for allow_user in allow_user_list: - pat = re.compile(allow_user) - mat = pat.match(dn) - if mat: + # pat = re.compile(allow_user) + # mat = pat.match(dn) + mat = dn.find(allow_user) + if mat > -1: matched = True break + if not matched: + return False, "User %s is not allowed" % str(dn) + if matched: # username = get_user_name_from_dn(dn) ban_user_list = X509Authentication().get_ban_user_list() From 8da2b5a1223050785aee39aac1ea74aa14ce85ef Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 22 Feb 2022 13:26:02 +0100 Subject: [PATCH 54/57] fix not to add_proxy for oidc --- client/lib/idds/client/clientmanager.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/lib/idds/client/clientmanager.py b/client/lib/idds/client/clientmanager.py index 6c151c94..0b4283e9 100644 --- a/client/lib/idds/client/clientmanager.py +++ b/client/lib/idds/client/clientmanager.py @@ -83,7 +83,11 @@ def setup_client(self, auth_setup=False): self.host = self.get_config_value(local_cfg, 'rest', 'host', current=self.host, default=None) if self.client is None: - self.client = Client(host=self.host, + if self.auth_type_host is not None: + client_host = self.auth_type_host + else: + client_host = self.host + self.client = Client(host=client_host, auth={'auth_type': self.auth_type, 'client_proxy': self.x509_proxy, 'oidc_token': self.oidc_token, @@ -311,7 +315,9 @@ def submit(self, workflow, username=None, userdn=None, use_dataset_name=True): 'workload_id': workflow.get_workload_id(), 'request_metadata': {'version': release_version, 'workload_id': workflow.get_workload_id(), 'workflow': workflow} } - workflow.add_proxy() + + if self.auth_type == 'x509_proxy': + workflow.add_proxy() if use_dataset_name: primary_init_work = workflow.get_primary_initial_collection() From 119ff308bab0f494628f801c428bdf23091df37f Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 22 Feb 2022 13:26:29 +0100 Subject: [PATCH 55/57] fix not to require auth for monitor --- main/lib/idds/rest/v1/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main/lib/idds/rest/v1/app.py b/main/lib/idds/rest/v1/app.py index 27d80eba..c16b80d4 100644 --- a/main/lib/idds/rest/v1/app.py +++ b/main/lib/idds/rest/v1/app.py @@ -61,7 +61,7 @@ def get_normal_blueprints(): bps.append(cacher.get_blueprint()) bps.append(hyperparameteropt.get_blueprint()) bps.append(logs.get_blueprint()) - bps.append(monitor.get_blueprint()) + # bps.append(monitor.get_blueprint()) bps.append(messages.get_blueprint()) bps.append(ping.get_blueprint()) @@ -71,6 +71,7 @@ def get_normal_blueprints(): def get_auth_blueprints(): bps = [] bps.append(auth.get_blueprint()) + bps.append(monitor.get_blueprint()) return bps From 92d41b7f9029a91ef1b99f1a292a1c0830accb52 Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 22 Feb 2022 13:26:42 +0100 Subject: [PATCH 56/57] fix --- main/lib/idds/tests/test_domapanda_workflow.py | 6 +++--- monitor/conf.js | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/main/lib/idds/tests/test_domapanda_workflow.py b/main/lib/idds/tests/test_domapanda_workflow.py index 48712617..5c78a3f3 100644 --- a/main/lib/idds/tests/test_domapanda_workflow.py +++ b/main/lib/idds/tests/test_domapanda_workflow.py @@ -25,7 +25,7 @@ # from idds.client.client import Client from idds.client.clientmanager import ClientManager # from idds.common.constants import RequestType, RequestStatus -from idds.common.utils import get_rest_host +# from idds.common.utils import get_rest_host # from idds.tests.common import get_example_real_tape_stagein_request # from idds.tests.common import get_example_prodsys2_tape_stagein_request @@ -171,9 +171,9 @@ def setup_workflow(): if __name__ == '__main__': - host = get_rest_host() + # host = get_rest_host() workflow = setup_workflow() - wm = ClientManager(host=host) + wm = ClientManager(host=None) request_id = wm.submit(workflow) print(request_id) diff --git a/monitor/conf.js b/monitor/conf.js index bb3e1caa..dd51c128 100644 --- a/monitor/conf.js +++ b/monitor/conf.js @@ -1,9 +1,9 @@ var appConfig = { - 'iddsAPI_request': "https://lxplus7104.cern.ch:443/idds/monitor_request/null/null", - 'iddsAPI_transform': "https://lxplus7104.cern.ch:443/idds/monitor_transform/null/null", - 'iddsAPI_processing': "https://lxplus7104.cern.ch:443/idds/monitor_processing/null/null", - 'iddsAPI_request_detail': "https://lxplus7104.cern.ch:443/idds/monitor/null/null/true/false/false", - 'iddsAPI_transform_detail': "https://lxplus7104.cern.ch:443/idds/monitor/null/null/false/true/false", - 'iddsAPI_processing_detail': "https://lxplus7104.cern.ch:443/idds/monitor/null/null/false/false/true" + 'iddsAPI_request': "https://lxplus735.cern.ch:443/idds/monitor_request/null/null", + 'iddsAPI_transform': "https://lxplus735.cern.ch:443/idds/monitor_transform/null/null", + 'iddsAPI_processing': "https://lxplus735.cern.ch:443/idds/monitor_processing/null/null", + 'iddsAPI_request_detail': "https://lxplus735.cern.ch:443/idds/monitor/null/null/true/false/false", + 'iddsAPI_transform_detail': "https://lxplus735.cern.ch:443/idds/monitor/null/null/false/true/false", + 'iddsAPI_processing_detail': "https://lxplus735.cern.ch:443/idds/monitor/null/null/false/false/true" } From b21e7951d70e3e96e6e4f2fae90918f18c3e50be Mon Sep 17 00:00:00 2001 From: Wen Guan Date: Tue, 22 Feb 2022 13:27:25 +0100 Subject: [PATCH 57/57] new version 0.10.2 --- atlas/lib/idds/atlas/version.py | 2 +- atlas/tools/env/environment.yml | 4 ++-- client/lib/idds/client/version.py | 2 +- client/tools/env/environment.yml | 4 ++-- common/lib/idds/common/version.py | 2 +- doma/lib/idds/doma/version.py | 2 +- doma/tools/env/environment.yml | 4 ++-- main/lib/idds/version.py | 2 +- main/tools/env/environment.yml | 6 +++--- monitor/version.py | 2 +- website/version.py | 2 +- workflow/lib/idds/workflow/version.py | 2 +- workflow/tools/env/environment.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/atlas/lib/idds/atlas/version.py b/atlas/lib/idds/atlas/version.py index 1ff8fb26..07e40fed 100644 --- a/atlas/lib/idds/atlas/version.py +++ b/atlas/lib/idds/atlas/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/atlas/tools/env/environment.yml b/atlas/tools/env/environment.yml index 397ebb5f..917004a2 100644 --- a/atlas/tools/env/environment.yml +++ b/atlas/tools/env/environment.yml @@ -11,5 +11,5 @@ dependencies: - nose # nose test tools - rucio-clients - rucio-clients-atlas - - idds-common==0.10.1 - - idds-workflow==0.10.1 \ No newline at end of file + - idds-common==0.10.2 + - idds-workflow==0.10.2 \ No newline at end of file diff --git a/client/lib/idds/client/version.py b/client/lib/idds/client/version.py index 1ff8fb26..07e40fed 100644 --- a/client/lib/idds/client/version.py +++ b/client/lib/idds/client/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/client/tools/env/environment.yml b/client/tools/env/environment.yml index 84692da6..f0b5df4c 100644 --- a/client/tools/env/environment.yml +++ b/client/tools/env/environment.yml @@ -14,5 +14,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - tabulate - - idds-common==0.10.1 - - idds-workflow==0.10.1 \ No newline at end of file + - idds-common==0.10.2 + - idds-workflow==0.10.2 \ No newline at end of file diff --git a/common/lib/idds/common/version.py b/common/lib/idds/common/version.py index 1ff8fb26..07e40fed 100644 --- a/common/lib/idds/common/version.py +++ b/common/lib/idds/common/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/doma/lib/idds/doma/version.py b/doma/lib/idds/doma/version.py index 84dcff62..1ae4d234 100644 --- a/doma/lib/idds/doma/version.py +++ b/doma/lib/idds/doma/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2020 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/doma/tools/env/environment.yml b/doma/tools/env/environment.yml index b766fd5d..fc60f0ff 100644 --- a/doma/tools/env/environment.yml +++ b/doma/tools/env/environment.yml @@ -10,5 +10,5 @@ dependencies: - pytest # python testing tool - nose # nose test tools - panda-client # panda client - - idds-common==0.10.1 - - idds-workflow==0.10.1 \ No newline at end of file + - idds-common==0.10.2 + - idds-workflow==0.10.2 \ No newline at end of file diff --git a/main/lib/idds/version.py b/main/lib/idds/version.py index 1ff8fb26..07e40fed 100644 --- a/main/lib/idds/version.py +++ b/main/lib/idds/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/main/tools/env/environment.yml b/main/tools/env/environment.yml index 06509d6c..3acaa40a 100644 --- a/main/tools/env/environment.yml +++ b/main/tools/env/environment.yml @@ -24,6 +24,6 @@ dependencies: - sphinx-rtd-theme # sphinx readthedoc theme - nevergrad # nevergrad hyper parameter optimization - psycopg2-binary - - idds-common==0.10.1 - - idds-workflow==0.10.1 - - idds-client==0.10.1 \ No newline at end of file + - idds-common==0.10.2 + - idds-workflow==0.10.2 + - idds-client==0.10.2 \ No newline at end of file diff --git a/monitor/version.py b/monitor/version.py index 1ff8fb26..07e40fed 100644 --- a/monitor/version.py +++ b/monitor/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/website/version.py b/website/version.py index 1ff8fb26..07e40fed 100644 --- a/website/version.py +++ b/website/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/workflow/lib/idds/workflow/version.py b/workflow/lib/idds/workflow/version.py index 1ff8fb26..07e40fed 100644 --- a/workflow/lib/idds/workflow/version.py +++ b/workflow/lib/idds/workflow/version.py @@ -9,4 +9,4 @@ # - Wen Guan, , 2019 - 2021 -release_version = "0.10.1" +release_version = "0.10.2" diff --git a/workflow/tools/env/environment.yml b/workflow/tools/env/environment.yml index 983e322d..dd2daca5 100644 --- a/workflow/tools/env/environment.yml +++ b/workflow/tools/env/environment.yml @@ -8,4 +8,4 @@ dependencies: - flake8 # Wrapper around PyFlakes&pep8 - pytest # python testing tool - nose # nose test tools - - idds-common==0.10.1 \ No newline at end of file + - idds-common==0.10.2 \ No newline at end of file