Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
python-version: ["3.9", "3.11", "3.12"]

steps:
- uses: actions/checkout@v2
Expand Down
115 changes: 115 additions & 0 deletions eva_sub_cli/call_home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import logging
import os
import traceback
import uuid
from datetime import datetime, timezone

import requests
from ebi_eva_common_pyutils.config import WritableConfig

from eva_sub_cli import __version__, SUB_CLI_CONFIG_FILE
from eva_sub_cli.submission_ws import _submission_ws_base_url

logger = logging.getLogger(__name__)

CALL_HOME_PATH = 'call-home'
DEPLOYMENT_ID_DIR = os.path.join(os.path.expanduser('~'), '.eva-sub-cli')
DEPLOYMENT_ID_FILE = os.path.join(DEPLOYMENT_ID_DIR, 'deployment_id')

EVENT_START = 'START'
EVENT_VALIDATION_COMPLETED = 'VALIDATION_COMPLETED'
EVENT_END = 'END'
EVENT_FAILURE = 'FAILURE'


def _get_call_home_url():
return os.path.join(_submission_ws_base_url(), CALL_HOME_PATH)


def _get_or_create_deployment_id():
try:
if os.path.isfile(DEPLOYMENT_ID_FILE):
with open(DEPLOYMENT_ID_FILE, 'r') as f:
deployment_id = f.read().strip()
if deployment_id:
return deployment_id
deployment_id = str(uuid.uuid4())
os.makedirs(DEPLOYMENT_ID_DIR, exist_ok=True)
with open(DEPLOYMENT_ID_FILE, 'w') as f:
f.write(deployment_id)
return deployment_id
except Exception:
logger.debug('Failed to read or create deployment ID file, using transient ID')
return str(uuid.uuid4())


def _get_or_create_run_id(submission_dir):
try:
config_path = os.path.join(submission_dir, SUB_CLI_CONFIG_FILE)
config = WritableConfig(config_path)
run_id = config.get('run_id')
if run_id:
return str(run_id)
run_id = str(uuid.uuid4())
config.set('run_id', value=run_id)
config.write()
return run_id
except Exception:
logger.debug('Failed to read or create run ID in config, using transient ID')
return str(uuid.uuid4())


class CallHomeClient:

def __init__(self, submission_dir, executor, tasks):
self.start_time = datetime.now(timezone.utc)
self.deployment_id = _get_or_create_deployment_id()
self.run_id = _get_or_create_run_id(submission_dir)
self.executor = executor
self.tasks = tasks

def _build_payload(self, event_type, **kwargs):
now = datetime.now(timezone.utc)
if event_type == EVENT_START:
runtime_seconds = 0
else:
elapsed = now - self.start_time
runtime_seconds = int(elapsed.total_seconds())
payload = {
'deploymentId': self.deployment_id,
'runId': self.run_id,
'eventType': event_type,
'cliVersion': __version__,
'createdAt': now.isoformat(),
'runtimeSeconds': runtime_seconds,
'executor': self.executor,
'tasks': self.tasks,
}
if kwargs:
payload.update(kwargs)
return payload

def _send_event(self, event_type, **kwargs):
try:
payload = self._build_payload(event_type, **kwargs)
requests.post(_get_call_home_url(), json=payload, timeout=5)
except Exception:
logger.debug('Failed to send %s call-home event', event_type)

def send_start(self):
self._send_event(EVENT_START)

def send_validation_completed(self, validation_result):
self._send_event(EVENT_VALIDATION_COMPLETED, validation_result=validation_result)

def send_end(self):
self._send_event(EVENT_END)

def send_failure(self, exception=None):
kwargs = {}
if exception is not None:
kwargs['exceptionMessage'] = str(exception)
kwargs['exceptionStacktrace'] = ''.join(
traceback.format_exception(type(exception), exception, exception.__traceback__)
)
self._send_event(EVENT_FAILURE, **kwargs)
73 changes: 73 additions & 0 deletions eva_sub_cli/etc/call_home_payload_schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Call-Home Event Payload",
"description": "Payload sent by the eva-sub-cli to the submission WS /call-home endpoint for usage telemetry.",
"type": "object",
"properties": {
"deploymentId": {
"type": "string",
"format": "uuid",
"description": "Persistent UUID identifying a unique CLI installation. Stored at ~/.eva-sub-cli/deployment_id."
},
"runId": {
"type": "string",
"format": "uuid",
"description": "Persistent UUID identifying a submission directory run. Stored in the .eva_sub_cli_config.yml file within the submission directory."
},
"eventType": {
"type": "string",
"enum": ["START", "VALIDATION_COMPLETED", "END", "FAILURE"],
"description": "Type of lifecycle event. START is sent when main() begins, VALIDATION_COMPLETED after validation finishes, END when the program completes, FAILURE on any error."
},
"cliVersion": {
"type": "string",
"description": "Version of the eva-sub-cli package (e.g. '0.5.3')."
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 UTC timestamp of when the event was created."
},
"runtimeSeconds": {
"type": "integer",
"minimum": 0,
"description": "Elapsed wall-clock seconds since the CLI started. Always 0 for START events."
},
"executor": {
"type": "string",
"enum": ["native", "docker"],
"description": "Execution backend selected for validation."
},
"tasks": {
"type": "array",
"items": {
"type": "string",
"enum": ["validate", "submit"]
},
"description": "List of tasks requested by the user."
},
"validation_result": {
"type": "string",
"description": "Outcome of the validation step. Only present in VALIDATION_COMPLETED events."
},
"exceptionMessage": {
"type": "string",
"description": "The exception message string. Only present in FAILURE events when an exception was caught."
},
"exceptionStacktrace": {
"type": "string",
"description": "Full Python stacktrace of the exception. Only present in FAILURE events when an exception was caught."
}
},
"required": [
"deploymentId",
"runId",
"eventType",
"cliVersion",
"createdAt",
"runtimeSeconds",
"executor",
"tasks"
],
"additionalProperties": true
}
34 changes: 32 additions & 2 deletions eva_sub_cli/executables/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
MetadataTemplateVersionNotFoundException
from eva_sub_cli.exceptions.submission_status_exception import SubmissionStatusException
from eva_sub_cli.exceptions.submission_not_found_exception import SubmissionNotFoundException
from eva_sub_cli.call_home import CallHomeClient
from eva_sub_cli.file_utils import is_submission_dir_writable, DirLockError, DirLock
from eva_sub_cli.orchestrator import VALIDATE, SUBMIT, DOCKER, NATIVE
from eva_sub_cli.validators.validator import ALL_VALIDATION_TASKS
Expand Down Expand Up @@ -107,42 +108,71 @@ def main():
else:
logging_config.add_stdout_handler(logging.INFO)

# Initialize call-home
call_home = None
try:
call_home = CallHomeClient(
submission_dir=args.submission_dir,
executor=args.executor,
tasks=args.tasks
)
call_home.send_start()
except Exception:
pass

caught_exception = None
try:
# lock the submission directory
with DirLock(os.path.join(args.submission_dir)) as lock:
# Create the log file
logging_config.add_file_handler(os.path.join(args.submission_dir, 'eva_submission.log'), logging.DEBUG)
# Pass on all the arguments to the orchestrator
orchestrator.orchestrate_process(**args.__dict__)
except DirLockError:
orchestrator.orchestrate_process(call_home=call_home, **args.__dict__)
except DirLockError as dle:
print(f'Could not acquire the lock file for {args.submission_dir} because another process is using this '
f'directory or a previous process did not terminate correctly. '
f'If the problem persists, remove the lock file manually.')
caught_exception = dle
exit_status = 65
except FileNotFoundError as fne:
print(fne)
caught_exception = fne
exit_status = 66
except SubmissionNotFoundException as snfe:
print(f'{snfe}. Please contact EVA Helpdesk')
caught_exception = snfe
exit_status = 67
except SubmissionStatusException as sse:
print(f'{sse}. Please try again later. If the problem persists, please contact EVA Helpdesk')
caught_exception = sse
exit_status = 68
except MetadataTemplateVersionException as mte:
print(mte)
caught_exception = mte
exit_status = 69
except MetadataTemplateVersionNotFoundException as mte:
print(mte)
caught_exception = mte
exit_status = 70
except SubmissionUploadException as sue:
print(sue)
caught_exception = sue
exit_status = 71
except HTTPError as http_err:
print(http_err)
if http_err.response is not None and http_err.response.text:
print(http_err.response.text)
caught_exception = http_err
exit_status = 72
except Exception as ex:
print(ex)
caught_exception = ex
exit_status = 73

if call_home is not None:
if exit_status == 0:
call_home.send_end()
else:
call_home.send_failure(caught_exception)

return exit_status
4 changes: 3 additions & 1 deletion eva_sub_cli/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ def check_validation_required(tasks, sub_config, username=None, password=None):

def orchestrate_process(submission_dir, metadata_json, metadata_xlsx,
tasks, executor, validation_tasks=ALL_VALIDATION_TASKS, username=None, password=None,
shallow_validation=False, nextflow_config=None, **kwargs):
shallow_validation=False, nextflow_config=None, call_home=None, **kwargs):
# load config
config_file_path = os.path.join(submission_dir, SUB_CLI_CONFIG_FILE)
sub_config = WritableConfig(config_file_path, version=__version__)
Expand Down Expand Up @@ -341,6 +341,8 @@ def orchestrate_process(submission_dir, metadata_json, metadata_xlsx,
nextflow_config=nextflow_config)
with validator:
validator.validate_and_report()
if call_home:
call_home.send_validation_completed(validator.results)
if not metadata_json:
metadata_json = os.path.join(validator.output_dir, 'metadata.json')
sub_config.set('metadata_json', value=metadata_json)
Expand Down
18 changes: 11 additions & 7 deletions eva_sub_cli/submission_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
from eva_sub_cli.auth import get_auth
from eva_sub_cli.exceptions.submission_upload_exception import SubmissionUploadException

DEFAULT_SUBMISSION_WS_URL = 'https://www.ebi.ac.uk/eva/webservices/submission-ws/v1/'


def _submission_ws_base_url():
"""Retrieve the base URL for the submission web services.
In order of preference from the environment variable or the hardcoded value."""
if os.environ.get(SUBMISSION_WS_VAR):
return os.environ.get(SUBMISSION_WS_VAR)
else:
return DEFAULT_SUBMISSION_WS_URL

class SubmissionWSClient(AppLogger):
"""
Expand All @@ -19,19 +29,13 @@ def __init__(self, username=None, password=None):
self.auth = get_auth(username, password)
self.base_url = self._submission_ws_url

SUBMISSION_WS_URL = 'https://www.ebi.ac.uk/eva/webservices/submission-ws/v1/'
SUBMISSION_INITIATE_PATH = 'submission/initiate'
SUBMISSION_UPLOADED_PATH = 'submission/{submissionId}/uploaded'
SUBMISSION_STATUS_PATH = 'submission/{submissionId}/status'

@property
def _submission_ws_url(self):
"""Retrieve the base URL for the submission web services.
In order of preference from the environment variable or the hardcoded value."""
if os.environ.get(SUBMISSION_WS_VAR):
return os.environ.get(SUBMISSION_WS_VAR)
else:
return self.SUBMISSION_WS_URL
return _submission_ws_base_url()

def _submission_initiate_url(self):
return os.path.join(self.base_url, self.SUBMISSION_INITIATE_PATH)
Expand Down
Loading