import argparse import asyncio import copy import getpass import glob import json import logging import os import sqlite3 import subprocess # FIXME: use asyncio.create_subprocess_* import traceback import urllib.parse from asyncio import get_event_loop, shield from datetime import datetime from pathlib import Path from typing import Any, Dict, List import yaml import zmq import zmq.asyncio import zmq.auth.thread from git import InvalidGitRepositoryError, Repo from metadata_client.metadata_client import MetadataClient try: from .messages import MDC, Errors, Success except ImportError: from messages import MDC, Errors, Success async def init_job_db(config): """ Initialize the sqlite job database A new database is created if no pre-existing one is present. A single table is created: jobs, which has columns: rid, id, proposal, run, flg, status :param config: the configuration parsed from the webservice YAML config :return: a sqlite3 connection instance to the database """ # FIXME: sqlite3 is synchronous, it should be replaced with # https://pypi.org/project/databases/ logging.info("Initializing database") conn = sqlite3.connect(config['web-service']['job-db']) c = conn.cursor() try: c.execute("SELECT * FROM jobs") except Exception: # TODO: is it sqlite3.OperationalError? logging.info("Creating initial job database") c.execute("CREATE TABLE jobs(rid, jobid, proposal, run, status, time, det, act)") return conn async def init_md_client(config): """ Initialize an MDC client connection :param config: the configuration parsed from the webservice YAML config :return: an MDC client connection """ # FIXME: this blocks the even loop, should use asyncio.Task # FIXME: calls to this coro should be shielded # TODO: could the client be a global? This would recuce passing it around mdconf = config['metadata-client'] client_conn = MetadataClient(client_id=mdconf['user-id'], client_secret=mdconf['user-secret'], user_email=mdconf['user-email'], token_url=mdconf['token-url'], refresh_url=mdconf['refresh-url'], auth_url=mdconf['auth-url'], scope=mdconf['scope'], base_api_url=mdconf['base-api-url']) return client_conn def init_config_repo(config): """ Make sure the configuration repo is present and up-to-data :param config: the configuration defined in the `config-repo` section """ # FIXME: keep this as func, but call this with asyncio.thread os.makedirs(config['local-path'], exist_ok=True) # check if it is a repo try: repo = Repo(config['local-path']) except InvalidGitRepositoryError: repo = Repo.clone_from(config['url'], config['local-path']) try: repo.remote().pull() except Exception: pass logging.info("Config repo is initialized") async def upload_config(socket, config, yaml, instrument, cycle, proposal): """ Upload a new configuration YAML :param socket: ZMQ socket to send reply on :param config: the configuration defined in the `config-repo` section of the webservice.yaml configuration. :param yaml: the YAML contents to update :param instrument: instrument for which the update is for :param cycle: the facility cylce the update is for :param proposal: the proposal the update is for The YAML contents will be placed into a file at {config.local-path}/{cycle}/{proposal}.yaml If it exists it is overwritten and then the new version is pushed to the configuration git repo. """ # FIXME: keep this as func, but call this with asyncio.thread repo = Repo(config['local-path']) # assure we are on most current version repo.remote().pull() prop_dir = os.path.join(repo.working_tree_dir, cycle) os.makedirs(prop_dir, exist_ok=True) with open("{}/{}.yaml".format(prop_dir, proposal), "w") as f: f.write(yaml) fpath = "{}/{}.yaml".format(prop_dir, proposal) repo.index.add([fpath]) repo.index.commit( "Update to proposal p{}: {}".format(proposal, datetime.now().isoformat())) repo.remote().push() logging.info(Success.UPLOADED_CONFIG.format(cycle, proposal)) socket.send(Success.UPLOADED_CONFIG.format(cycle, proposal).encode()) def merge(source: Dict, destination: Dict) -> Dict: """Deep merge two dictionaries. :param source: source dictionary to merge into destination :param destination: destination dictionary which is being merged in :return: the updated destination dictionary Taken from: https://stackoverflow.com/questions/20656135/ python-deep-merge-dictionary-data """ for key, value in source.items(): if isinstance(value, dict): # check if destination (e.g. karabo-id) has none value if key in destination.keys() and destination[key] is None: destination[key] = {} # get node or create one node = destination.setdefault(key, {}) merge(value, node) else: destination[key] = value return destination async def change_config(socket, config, updated_config, karabo_id, instrument, cycle, proposal, apply=False): """ Change the configuration of a proposal If no proposal specific configuration yet exists, one is first created based on the default configuration of the proposal Changes are committed to git. :param socket: ZMQ socket to send reply on :param config: repo config as given in YAML config file :param updated_config: a dictionary containing the updated config :param instrument: the instrument to change config for :param cycle: the cycle to change config for :param proposal: the proposal to change config for :param apply: set to True to actually commit a change, otherwise a dry-run is performed :return: The updated config to the requesting zmq socket """ # first check if a proposal specific config exists, if not create one repo = Repo(config['local-path']) repo.remote().pull() prop_dir = os.path.join(repo.working_tree_dir, cycle) os.makedirs(prop_dir, exist_ok=True) fpath = "{}/{:06d}.yaml".format(prop_dir, int(proposal)) if not os.path.exists(fpath): with open("{}/default.yaml".format(repo.working_tree_dir), "r") as f: defconf = yaml.load(f.read(), Loader=yaml.FullLoader) subconf = {} for action, instruments in defconf.items(): subconf[action] = {} if action != "data-mapping": subconf[action][instrument] = instruments[instrument] else: subconf[action][karabo_id] = instruments[karabo_id] with open(fpath, "w") as wf: wf.write(yaml.dump(subconf, default_flow_style=False)) new_conf = None with open(fpath, "r") as rf: existing_conf = yaml.load(rf.read(), Loader=yaml.FullLoader) new_conf = merge(updated_config, existing_conf) if apply: with open(fpath, "w") as wf: wf.write(yaml.dump(new_conf, default_flow_style=False)) repo.index.add([fpath]) repo.index.commit( "Update to proposal YAML: {}".format(datetime.now().isoformat())) repo.remote().push() logging.info(Success.UPLOADED_CONFIG.format(cycle, proposal)) socket.send(yaml.dump(new_conf, default_flow_style=False).encode()) async def slurm_status(filter_user=True): """ Return the status of slurm jobs by calling squeue :param filter_user: set to true to filter ony jobs from current user :return: a dictionary indexed by slurm jobid and containing a tuple of (status, run time) as values. """ cmd = ["squeue"] if filter_user: cmd += ["-u", getpass.getuser()] ret = subprocess.run(cmd, stdout=subprocess.PIPE) # FIXME: asyncio if ret.returncode == 0: rlines = ret.stdout.decode().split("\n") statii = {} for r in rlines[1:]: try: jobid, _, _, _, status, runtime, _, _ = r.split() jobid = jobid.strip() statii[jobid] = status, runtime except ValueError: # not enough values to unpack in split pass return statii return None async def slurm_job_status(jobid): """ Return the status of slurm job :param jobid: Slurm job Id :return: Slurm state, Elapsed. """ cmd = ["sacct", "-j", str(jobid), "--format=JobID,Elapsed,state"] ret = subprocess.run(cmd, stdout=subprocess.PIPE) # FIXME: asyncio if ret.returncode == 0: rlines = ret.stdout.decode().split("\n") logging.debug("Job {} state {}".format(jobid, rlines[2].split())) if len(rlines[2].split()) == 3: return rlines[2].replace("+", "").split() return "NA", "NA", "NA" async def query_rid(conn, socket, rid): c = conn.cursor() c.execute("SELECT * FROM jobs WHERE rid LIKE ?", rid) combined = {} for r in c.fetchall(): rid, jobid, proposal, run, status, time_, _ = r logging.debug( "Job {}, proposal {}, run {} has status {}".format(jobid, proposal, run, status)) cflg, cstatus = combined.get(rid, ([], [])) if status in ['R', 'PD']: flg = 'R' elif status == 'NA': flg = 'NA' else: flg = 'A' cflg.append(flg) cstatus.append(status) combined[rid] = cflg, cstatus flg_order = {"R": 2, "A": 1, "NA": 0} msg = "" for rid, value in combined.items(): flgs, statii = value flg = max(flgs, key=lambda i: flg_order[i]) msg += "\n".join(statii) if msg == "": msg = 'NA' socket.send(msg.encode()) def parse_config(cmd: List[str], config: Dict[str, Any]) -> List[str]: """Convert a dictionary to a list of arguments. Values that are not strings will be cast. Lists will be converted to several strings following their `--key` flag. Booleans will be converted to a `--key` flag, where `key` is the dictionary key. """ for key, value in config.items(): if ' ' in key or (isinstance(value, str) and ' ' in value): raise ValueError('Spaces are not allowed', key, value) if isinstance(value, list): cmd.append(f"--{key}") cmd += [str(v) for v in value] elif isinstance(value, bool): if value: cmd += ["--{}".format(key)] else: if value in ['""', "''"]: value = "" cmd += ["--{}".format(key), str(value)] return cmd async def update_job_db(config): """ Update the job database and send out updates to MDC This coro runs as background task :param config: configuration parsed from webservice YAML """ logging.info("Starting config db handling") conn = await init_job_db(config) mdc = await init_md_client(config) time_interval = int(config['web-service']['job-update-interval']) while True: statii = await slurm_status() # Check that slurm is giving proper feedback if statii is None: await asyncio.sleep(time_interval) continue try: c = conn.cursor() c.execute("SELECT * FROM jobs WHERE status IN ('R', 'PD', 'CG') ") combined = {} logging.debug("SLURM info {}".format(statii)) for r in c.fetchall(): rid, jobid, proposal, run, status, time, _, action = r logging.debug("DB info {}".format(r)) cflg, cstatus = combined.get(rid, ([], [])) if jobid in statii: slstatus, runtime = statii[jobid] query = "UPDATE jobs SET status=?, time=? WHERE jobid LIKE ?" c.execute(query, (slstatus, runtime, jobid)) cflg.append('R') cstatus.append("{}-{}".format(slstatus, runtime)) else: _, sltime, slstatus = await slurm_job_status(jobid) query = "UPDATE jobs SET status=? WHERE jobid LIKE ?" c.execute(query, (jobid, slstatus)) if slstatus == 'COMPLETED': cflg.append("A") else: cflg.append("NA") cstatus.append(slstatus) combined[rid] = cflg, cstatus conn.commit() flg_order = {"R": 2, "A": 1, "NA": 0} dark_flags = {'NA': 'E', 'R': 'IP', 'A': 'F'} for rid, value in combined.items(): if int(rid) == 0: # this job was not submitted from MyMDC continue flgs, statii = value # sort by least done status flg = max(flgs, key=lambda i: flg_order[i]) msg = "\n".join(statii) msg_debug = f"Update MDC {rid}, {msg}" logging.debug(msg_debug.replace('\n', ', ')) if action == 'CORRECT': response = mdc.update_run_api(rid, {'flg_cal_data_status': flg, 'cal_pipeline_reply': msg}) else: # action == 'DARK' but it's dark_request data = {'dark_run': {'flg_status': dark_flags[flg], 'calcat_feedback': msg}} response = mdc.update_dark_run_api(rid, data) if response.status_code != 200: logging.error(Errors.MDC_RESPONSE.format(response)) except Exception as e: e = str(e) logging.error(f"Failure to update job DB: {e}") await asyncio.sleep(time_interval) async def copy_untouched_files(file_list, out_folder, run): """ Copy those files whicha are not touched by calibration to outpot dir :param file_list: The list of files to copy :param out_folder: The output folder :param run: The run which is being handled Copying is done via an asyncio subprocess call """ os.makedirs("{}/r{:04d}".format(out_folder, int(run)), exist_ok=True) for f in file_list: of = f.replace("raw", "proc").replace("RAW", "CORR") cmd = ["rsync", "-av", f, of] await asyncio.subprocess.create_subprocess_shell(" ".join(cmd)) logging.info("Copying {} to {}".format(f, of)) async def run_action(job_db, cmd, mode, proposal, run, rid): """ Run action command (CORRECT OR DARK) :param job_db: jobs database :param cmd: to run, should be a in list for as expected by subprocess.run :param mode: "prod" or "sim", in the latter case nothing will be executed but the command will be logged :param proposal: proposal the command was issued for :param run: run the command was issued for :param: rid: run id in the MDC Returns a formatted Success or Error message indicating outcome of the execution. """ # FIXME: this coro has too many returns that can be simplified if mode == "prod": logging.info(" ".join(cmd)) ret = subprocess.run(cmd, stdout=subprocess.PIPE) # FIXME: asyncio if ret.returncode == 0: if "DARK" in cmd: logging.info(Success.START_CHAR.format(proposal, run)) else: logging.info(Success.START_CORRECTION.format(proposal, run)) # enter jobs in job db c = job_db.cursor() # FIXME: asyncio rstr = ret.stdout.decode() query = "INSERT INTO jobs VALUES ('{rid}', '{jobid}', '{proposal}', '{run}', 'PD', '{now}', '{det}', '{act}')" # noqa for r in rstr.split("\n"): if "Submitted job:" in r: _, jobid = r.split(":") c.execute( "INSERT INTO jobs VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (rid, jobid.strip(), proposal, run, datetime.now().isoformat(), cmd[3], cmd[4]) ) job_db.commit() logging.debug((" ".join(cmd)).replace(',', '').replace("'", "")) if "DARK" in cmd: return Success.START_CHAR.format(proposal, run) else: return Success.START_CORRECTION.format(proposal, run) else: logging.error(Errors.JOB_LAUNCH_FAILED.format(cmd, ret.returncode)) return Errors.JOB_LAUNCH_FAILED.format(cmd, ret.returncode) else: if "DARK" in cmd: logging.debug(Success.START_CHAR_SIM.format(proposal, run)) else: logging.debug(Success.START_CORRECTION_SIM.format(proposal, run)) logging.debug((" ".join(cmd)).replace(',', '').replace("'", "")) if "DARK" in cmd: return Success.START_CHAR_SIM.format(proposal, run) else: return Success.START_CORRECTION_SIM.format(proposal, run) async def wait_on_transfer(rpath, max_tries=300): """ Wait on data files to be transferred to Maxwell :param rpath: Folder, which contains data files migrated to Maxwell :param max_tries: Maximum number of checks if files are transferred :return: True if files are transferred """ # TODO: Make use of MyMDC to request whether the run has been copied. # It is not sufficient to know that the files are on disk, but also to # check the copy is finished (ie. that the files are complete). if 'pnfs' in os.path.realpath(rpath): return True rstr = None ret = None tries = 0 # FIXME: if not kafka, then do event-driven, no sleep # wait until folder gets created while not os.path.exists(rpath): if tries > max_tries: return False tries += 1 await asyncio.sleep(10) # FIXME: if not kafka, then do event-driven, no sleep # wait until files are migrated while rstr is None or 'status="online"' in rstr or 'status="Online"' in rstr or ret.returncode != 0: # noqa await asyncio.sleep(10) # FIXME: make use of asyncio.subprocess.run ret = subprocess.run(["getfattr", "-n", "user.status", rpath], stdout=subprocess.PIPE) rstr = ret.stdout.decode() if tries > max_tries: return False tries += 1 return ret.returncode == 0 def check_files(in_folder: str, runs: List[int], karabo_das: List[str]) -> bool: """Check if files for given karabo-das exists for given runs :param in_folder: Input folder :param runs: List of runs :param karabo_das: List of karabo-das :return: True if files are there """ files_exists = True for runnr in runs: rpath = Path(in_folder, 'r{:04d}'.format(int(runnr))) fl = rpath.glob('*.h5') if not any(da in match.name for match in fl for da in karabo_das): files_exists = False return files_exists async def update_darks_paths(mdc: MetadataClient, rid: int, in_path: str, out_path: str, report_path: str): """Update data paths in MyMDC to provide Globus access DarkRequests have the capability to provide a link to the report via Globus. For this, the absolute path to the report needs to be provided. Additionally, the input path to the raw data, as well as the output path to the processed data can be specified. :param mdc: an authenticated MyMDC client :param rid: the DarkRequest id to update :param in_path: the absolute path to the raw data being calibrated :param out_path: the absolute path to the resulting processed data :param report_path: the absolute path to the PDF report """ data = {'dark_run': {'output_path': out_path, 'input_path': in_path, 'globus_url': report_path}} loop = get_event_loop() response = await shield(loop.run_in_executor(None, mdc.update_dark_run_api, rid, data)) if response.status_code != 200: logging.error(Errors.MDC_RESPONSE.format(response)) async def update_mdc_status(mdc: MetadataClient, action: str, rid: int, message: str): """Update MyMDC statuses The message content and destination depend on the Action type. A message to MyMDC expects a status flag, which differ between Correction and DarkRequest. Correction can have NA (Not Available), R(unning), A(vailable) DarkRequest can have F(inished), E(rror), R(equested), IP (In Progress), T(imeout) The flag is extracted from the message. Further informations are available at: https://git.xfel.eu/gitlab/detectors/pycalibration/wikis/MyMDC-Communication """ if message.split(':')[0] in ('FAILED', 'WARN'): # Errors flag = 'NA' if action == 'correct' else 'E' elif message.split(':')[0] == 'SUCCESS': # Success flag = 'R' if action == 'correct' else 'IP' if 'Uploaded' in message or 'Finished' in message: flag = 'A' if action == 'correct' else 'F' else: # MDC Timeout flag = 'NA' if action == 'correct' else 'T' if action == 'correct': func = mdc.update_run_api data = {'flg_cal_data_status': flag, 'cal_pipeline_reply': message} if action == 'dark_request': func = mdc.update_dark_run_api data = {'dark_run': {'flg_status': flag, 'calcat_feedback': message}} loop = get_event_loop() response = await shield(loop.run_in_executor(None, func, rid, data)) if response.status_code != 200: logging.error(Errors.MDC_RESPONSE.format(response)) async def server_runner(config, mode): """ The main server loop The main server loop handles remote requests via a ZMQ interface. Requests are the form of ZMQ.REQuest and have the format command, *params where *parms is a string-encoded python list as defined by the commands. The following commands are currently understood: - correct, with parmeters rid, sase, instrument, cycle, proposal, runnr where :param rid: is the runid within the MDC database :param sase: is the sase beamline :param instrument: is the instrument :param cycle: is the facility cycle :param proposal: is the proposal id :param runnr: is the run number in integer form, e.g. without leading "r" This will trigger a correction process to be launched for that run in the given cycle and proposal. - dark_request, with parameters rid, sase, instrument, cycle, proposal, did, operation_mode, pdu_names, karabo_das, runnr where :param rid: is the runid within the MDC database :param sase: is the sase beamline :param instrument: is the instrument :param cycle: is the facility cycle :param proposal: is the proposal id :param did: is the detector karabo id :param operation_mode: is the detector's operation mode, as defined in CalCat :param pdu_names: physical detector units for each modules :param karabo_das: the Data Agreggators representing which detector modules to calibrate :param runnr: is the run number in integer form, i.e. without leading "r" - upload-yaml, with parameters sase, instrument, cycle, proposal, yaml where :param sase: is the sase beamline :param instrument: is the instrument :param cycle: is the facility cycle :param proposal: is the proposal id :param yaml: is url-encoded (quotes and spaces) representation of new YAML file This will create or replace the existing YAML configuration for the proposal and cycle with the newly sent one, and then push it to the git configuration repo. """ init_config_repo(config['config-repo']) job_db = await init_job_db(config) mdc = await init_md_client(config) context = zmq.asyncio.Context() auth = zmq.auth.thread.ThreadAuthenticator(context) if mode == "prod-auth": auth.start() auth.allow(config['web-service']['allowed-ips']) socket = context.socket(zmq.REP) socket.zap_domain = b'global' socket.bind("{}:{}".format(config['web-service']['bind-to'], config['web-service']['port'])) while True: response = await socket.recv_multipart() if isinstance(response, list) and len(response) == 1: try: # protect against unparseable requests response = eval(response[0]) except SyntaxError as e: logging.error(str(e)) socket.send(Errors.REQUEST_FAILED.encode()) continue if len(response) < 2: # catch parseable but malformed requests logging.error(Errors.REQUEST_MALFORMED.format(response)) socket.send(Errors.REQUEST_MALFORMED.format(response).encode()) continue # FIXME: action should be an enum action, payload = response[0], response[1:] if action not in ['correct', 'dark', 'dark_request', 'query-rid', 'upload-yaml', 'update_conf']: logging.warning(Errors.UNKNOWN_ACTION.format(action)) socket.send(Errors.UNKNOWN_ACTION.format(action).encode()) continue logging.debug('{}, {}'.format(action, payload)) if action == "query-rid": rid = payload[0] await query_rid(job_db, socket, rid) continue async def do_action(action, payload): # FIXME: this needn't be nested in_folder = None run_mapping = {} priority = None # TODO: Investigate argument if action == 'update_conf': updated_config = None try: sase, karabo_id, instrument, cycle, proposal, config_yaml, apply = payload # noqa updated_config = json.loads(config_yaml) await change_config(socket, config['config-repo'], updated_config, karabo_id, instrument, cycle, proposal, apply.upper() == "TRUE") except Exception as e: e = str(e) err_msg = (f"Failure applying config for {proposal}:" f" {e}: {updated_config}") logging.error(err_msg) logging.error(f"Unexpected error: {traceback.format_exc()}") # noqa socket.send(yaml.dump(err_msg, default_flow_style=False).encode()) if action in ['dark', 'correct', 'dark_request']: request_time = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') try: wait_runs: List[str] = [] rid, sase, instrument, cycle, proposal, *payload = payload if action == 'correct': runnr, priority = payload runnr = runnr.strip('r') wait_runs = [runnr] if action == 'dark': karabo_ids, karabo_das, *runs = payload karabo_ids = karabo_ids.split(',') karabo_das = karabo_das.split(',') for i, run in enumerate(runs): erun = eval(run) if isinstance(erun, (list, tuple)): typ, runnr = erun if typ == "reservation": continue runnr = runnr.strip('r') run_mapping[typ] = runnr wait_runs.append(runnr) else: run_mapping['no_mapping_{}'.format(i)] = erun wait_runs.append(erun) if action == 'dark_request': karabo_id, operation_mode, *payload = payload payload = eval(','.join(payload)) pdus, karabo_das, wait_runs = payload karabo_das = [val.strip() for val in karabo_das] wait_runs = [str(val) for val in wait_runs] proposal = proposal.strip('p') proposal = "{:06d}".format(int(proposal)) logging.info(f'{action} of {proposal} run {wait_runs} at ' f'{instrument} is requested. Checking files.') # Read calibration configuration from yaml conf_file = Path(config['config-repo']['local-path'], cycle, f'{proposal}.yaml') if not conf_file.exists(): conf_file = Path(config['config-repo']['local-path'], "default.yaml") with open(conf_file, "r") as f: pconf_full = yaml.load(f.read(), Loader=yaml.FullLoader) # FIXME: remove once MyMDC sends `dark` action action_ = 'dark' if action == 'dark_request' else action data_conf = pconf_full['data-mapping'] if instrument in pconf_full[action_]: pconf = pconf_full[action_][instrument] else: socket.send(Errors.NOT_CONFIGURED.encode()) logging.info(f'Instrument {instrument} is unknown') return in_folder = config[action_]['in-folder'].format( instrument=instrument, cycle=cycle, proposal=proposal) msg = Success.QUEUED.format(proposal, wait_runs) socket.send(msg.encode()) logging.debug(msg) if action in ['correct', 'dark_request']: await update_mdc_status(mdc, action, rid, msg) except Exception as e: e = str(e) msg = Errors.JOB_LAUNCH_FAILED.format(action, e) logging.error(msg) socket.send(msg.encode()) if action in ['correct', 'dark_request']: await update_mdc_status(mdc, action, rid, msg) return # Check if all files for given runs are transferred all_transfers = [] # FIXME: this loop should be an asyncio.gather for runnr in wait_runs: rpath = "{}/r{:04d}/".format(in_folder, int(runnr)) transfer_complete = await wait_on_transfer(rpath) all_transfers.append(transfer_complete) if not transfer_complete: logging.error( Errors.TRANSFER_EVAL_FAILED.format(proposal, runnr)) if action in ['correct', 'dark_request']: await update_mdc_status(mdc, action, rid, MDC.MIGRATION_TIMEOUT) if not all(all_transfers): logging.error(Errors.TRANSFER_EVAL_FAILED.format(proposal, ','.join(wait_runs))) # noqa return logging.debug(f"Now doing: {action}") ts = datetime.now().strftime('%y%m%d_%H%M%S') if action == 'dark': detectors = {} out_folder = config[action]['out-folder'].format( instrument=instrument, cycle=cycle, proposal=proposal, runs="_".join(wait_runs)) # Run over all available detectors if karabo_ids[0] == 'all': karabo_ids = list(pconf.keys()) # Prepare configs for all requested detectors for karabo_id in karabo_ids: # use selected karabo_das if karabo_das[0] == 'all': karabo_da = data_conf[karabo_id]["karabo-da"] # Check if any files for given karabo-das exists if check_files(in_folder, wait_runs, karabo_da): thisconf = copy.copy(data_conf[karabo_id]) if (karabo_id in pconf and isinstance(pconf[karabo_id], dict)): thisconf.update(copy.copy(pconf[karabo_id])) thisconf["in-folder"] = in_folder thisconf["out-folder"] = '/'.join((out_folder, karabo_id.replace('-', '_'))) # noqa FIXME Make use of pathlib thisconf["karabo-id"] = karabo_id thisconf["karabo-da"] = karabo_da run_config = [] for typ, run in run_mapping.items(): if "no_mapping" in typ: run_config.append(run) else: thisconf[typ] = run if len(run_config): thisconf["runs"] = ",".join(run_config) detectors[karabo_id] = thisconf else: logging.warning("File list for {} at {} is empty" .format(karabo_id, "{}/*.h5".format(rpath))) if len(detectors) == 0: logging.warning(Errors.NOTHING_TO_DO.format(rpath)) if action == 'correct': try: runnr = wait_runs[0] rpath = "{}/r{:04d}/".format(in_folder, int(runnr)) out_folder = config[action]['out-folder'].format( instrument=instrument, cycle=cycle, proposal=proposal, run='r{:04d}'.format(int(runnr))) # Prepare configs for all detectors in run fl = glob.glob(f"{rpath}/*.h5") corr_file_list = set() copy_file_list = set(fl) detectors = {} for karabo_id in pconf: dconfig = data_conf[karabo_id] # check for files according to mapping in raw run dir. if any(y in x for x in fl for y in dconfig['karabo-da']): for karabo_da in dconfig['karabo-da']: tfl = glob.glob(f"{rpath}/*{karabo_da}*.h5") corr_file_list = corr_file_list.union(set(tfl)) thisconf = copy.copy(dconfig) if isinstance(pconf[karabo_id], dict): thisconf.update(copy.copy(pconf[karabo_id])) thisconf["in-folder"] = in_folder thisconf["out-folder"] = out_folder thisconf["karabo-id"] = karabo_id thisconf["run"] = runnr if priority: thisconf["priority"] = str(priority) detectors[karabo_id] = thisconf copy_file_list = copy_file_list.difference(corr_file_list) asyncio.ensure_future(copy_untouched_files(copy_file_list, out_folder, runnr)) if len(detectors) == 0: logging.warning(Errors.NOTHING_TO_DO.format(rpath)) await update_mdc_status(mdc, action, rid, MDC.NOTHING_TO_DO) return except Exception as corr_e: logging.error(f"Error during correction: {str(corr_e)}") await update_mdc_status(mdc, action, rid, Errors.REQUEST_FAILED) if action == 'dark_request': runs = [str(r) for r in wait_runs] # Notebooks require one or three runs, depending on the # detector type and operation mode. triple = any(det in karabo_id for det in ["LPD", "AGIPD", "JUNGFRAU", "JF", "JNGFR"]) if triple and len(runs) == 1: runs_dict = {'run-high': runs[0], 'run-med': '0', 'run-low': '0'} elif triple and len(runs) == 3: runs_dict = {'run-high': runs[0], 'run-med': runs[1], 'run-low': runs[2]} else: # single runs_dict = {'run': runs[0]} out_folder = config['dark']['out-folder'].format( instrument=instrument, cycle=cycle, proposal=proposal, runs='_'.join(runs)) out_folder = str(Path(out_folder, karabo_id.replace('-', '_'))) # We assume that MyMDC does not allow dark request if the data # is not migrated, thus skipping some validation here. thisconf = copy.copy(data_conf[karabo_id]) if (karabo_id in pconf and isinstance(pconf[karabo_id], dict)): thisconf.update(copy.copy(pconf[karabo_id])) thisconf['in-folder'] = in_folder thisconf['out-folder'] = out_folder thisconf['karabo-id'] = karabo_id thisconf['karabo-da'] = karabo_das thisconf['operation-mode'] = operation_mode thisconf.update(runs_dict) detectors = {karabo_id: thisconf} if action in ['correct', 'dark', 'dark_request']: # run xfel_calibrate action_ = 'dark' if action == 'dark_request' else action for karabo_id, dconfig in detectors.items(): detector = dconfig['detector-type'] del dconfig['detector-type'] cmd = config[action_]['cmd'].format( detector=detector, sched_prio=str(config[action_]['sched-prio']), action=action_, instrument=instrument, cycle=cycle, proposal=proposal, runs="_".join([f"r{r}" for r in wait_runs]), time_stamp=ts, det_instance=karabo_id, request_time=request_time ).split() cmd = parse_config(cmd, dconfig) rid = rid if action in ['correct', 'dark_request'] else 0 ret = await run_action(job_db, cmd, mode, proposal, runnr, rid) if action == 'correct': await update_mdc_status(mdc, action, rid, ret) if action == 'dark_request': await update_mdc_status(mdc, action, rid, ret) report_idx = cmd.index('--report-to') + 1 report = cmd[report_idx] + '.pdf' await update_darks_paths(mdc, rid, in_folder, out_folder, report) # TODO: moving this block further up reduces the need of so # many nested ifs. Move up and return directly if action == 'upload-yaml': sase, instrument, cycle, proposal, this_yaml = payload this_yaml = urllib.parse.unquote_plus(this_yaml) await upload_config(socket, config['config-repo'], this_yaml, instrument, cycle, proposal) try: asyncio.ensure_future( do_action(copy.copy(action), copy.copy(payload))) except Exception as e: # actions that fail are only error logged logging.error(str(e)) parser = argparse.ArgumentParser( description='Start the calibration webservice') parser.add_argument('--config-file', type=str, default='./webservice.yaml') parser.add_argument('--log-file', type=str, default='./web.log') parser.add_argument('--mode', type=str, default="sim", choices=['sim', 'prod']) parser.add_argument('--logging', type=str, default="INFO", choices=['INFO', 'DEBUG', 'ERROR']) if __name__ == "__main__": args = vars(parser.parse_args()) conf_file = args["config_file"] with open(conf_file, "r") as f: config = yaml.load(f.read(), Loader=yaml.FullLoader) mode = args["mode"] logfile = args["log_file"] fmt = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' logging.basicConfig(filename=logfile, level=getattr(logging, args['logging']), format=fmt) loop = asyncio.get_event_loop() loop.create_task(update_job_db(config)) loop.run_until_complete(server_runner(config, mode)) loop.close()