diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index dcb994142..c1956f193 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -13,7 +13,7 @@ jobs: name: Generate OpenAPI docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Generate docs run: docker run -u $(id -u):$(id -g) --rm -v "${PWD}:/local" openapitools/openapi-generator-cli generate -g html -i /local/specs/api.yaml -o /local/html/ - name: Setup assets diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index c660d4929..a6ff9342e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -37,9 +37,9 @@ jobs: python-version: ["3.11"] steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python v${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install flake8 @@ -56,9 +56,9 @@ jobs: python-version: ["3.11"] steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python v${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install OpenAPI Spec Validator @@ -74,7 +74,7 @@ jobs: - name: "List Docker images" run: "docker images" - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index a2c7bce23..c83858c16 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -23,11 +23,32 @@ else mkdir -p ${metrics_base_path} export PROMETHEUS_MULTIPROC_DIR=$(mktemp -d ${metrics_base_path}/backend.XXXXXXXX) fi + + # gunicorn settings export GUNICORN_SERVER="true" - gunicorn --workers "${GUNICORN_WORKERS}" \ - --threads "${GUNICORN_THREADS}" \ - --config "${GUNICORN_CONF}" \ - --certfile="${CERT}" --keyfile="${KEY}" \ - -b "0.0.0.0:8000" \ - "app" + export GUNICORN_WORKERS="${GUNICORN_WORKERS:-2}" + export GUNICORN_THREADS="${GUNICORN_THREADS:-1}" + export GUNICORN_WORKER_CLASS="${GUNICORN_WORKER_CLASS:-sync}" + export GUNICORN_MAX_REQUESTS="${GUNICORN_MAX_REQUESTS:-0}" + export GUNICORN_MAX_REQUESTS_JITTER="${GUNICORN_MAX_REQUESTS_JITTER:-0}" + export GUNICORN_WORKER_CONNECTIONS="${GUNICORN_WORKER_CONNECTIONS:-1000}" + export GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-30}" + export GUNICORN_GRACEFUL_TIMEOUT="${GUNICORN_GRACEFUL_TIMEOUT:-30}" + export GUNICORN_KEEPALIVE="${GUNICORN_KEEPALIVE:-2}" + + # run app with gunicorn + printf "Starting app in PROD mode (Gunicorn)" + gunicorn --workers "${GUNICORN_WORKERS}" \ + --threads "${GUNICORN_THREADS}" \ + --max-requests "${GUNICORN_MAX_REQUESTS}" \ + --max-requests-jitter "${GUNICORN_MAX_REQUESTS_JITTER}" \ + --worker-connections "${GUNICORN_WORKER_CONNECTIONS}" \ + --worker-class "${GUNICORN_WORKER_CLASS}" \ + --timeout "${GUNICORN_TIMEOUT}" \ + --graceful-timeout "${GUNICORN_GRACEFUL_TIMEOUT}" \ + --keep-alive "${GUNICORN_KEEPALIVE}" \ + --config "${GUNICORN_CONF}" \ + --certfile="${CERT}" --keyfile="${KEY}" \ + -b "0.0.0.0:8000" \ + "app" fi diff --git a/docker/wss-entrypoint.sh b/docker/wss-entrypoint.sh index 278e3125c..d0be67729 100755 --- a/docker/wss-entrypoint.sh +++ b/docker/wss-entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Copyright (c) 2020-2022 CRS4 +# Copyright (c) 2020-2024 CRS4 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml index 73ea27b97..12954db09 100644 --- a/k8s/Chart.yaml +++ b/k8s/Chart.yaml @@ -7,12 +7,12 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.11.0 +version: 0.12.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. -appVersion: 0.12.0 +appVersion: 0.13.0 # Chart dependencies dependencies: diff --git a/k8s/templates/_helpers.tpl b/k8s/templates/_helpers.tpl index cd046970a..c6d079da1 100644 --- a/k8s/templates/_helpers.tpl +++ b/k8s/templates/_helpers.tpl @@ -135,6 +135,22 @@ Define environment variables shared by some pods. value: "{{ .Values.worker.processes }}" - name: WORKER_THREADS value: "{{ .Values.worker.threads }}" +- name: GUNICORN_WORKERS + value: "{{ .Values.lifemonitor.gunicorn.workers }}" +- name: GUNICORN_THREADS + value: "{{ .Values.lifemonitor.gunicorn.threads }}" +- name: GUNICORN_MAX_REQUESTS + value: "{{ .Values.lifemonitor.gunicorn.max_requests }}" +- name: GUNICORN_MAX_REQUESTS_JITTER + value: "{{ .Values.lifemonitor.gunicorn.max_requests_jitter }}" +- name: GUNICORN_WORKER_CONNECTIONS + value: "{{ .Values.lifemonitor.gunicorn.worker_connections }}" +- name: GUNICORN_GRACEFUL_TIMEOUT + value: "{{ .Values.lifemonitor.gunicorn.graceful_timeout }}" +- name: GUNICORN_TIMEOUT + value: "{{ .Values.lifemonitor.gunicorn.timeout }}" +- name: GUNICORN_KEEPALIVE + value: "{{ .Values.lifemonitor.gunicorn.keepalive }}" - name: LIFEMONITOR_TLS_KEY value: "/lm/certs/tls.key" - name: LIFEMONITOR_TLS_CERT diff --git a/k8s/templates/nginx-configmap.yaml b/k8s/templates/nginx-configmap.yaml index 2c8594765..dc77e0be2 100644 --- a/k8s/templates/nginx-configmap.yaml +++ b/k8s/templates/nginx-configmap.yaml @@ -1,111 +1,157 @@ apiVersion: v1 kind: ConfigMap metadata: - name: lifemonitor-nginx-configmap - labels: - app.kubernetes.io/name: {{ include "chart.name" . }} - helm.sh/chart: {{ include "chart.chart" . }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/managed-by: {{ .Release.Service }} + name: lifemonitor-nginx-configmap + labels: + app.kubernetes.io/name: { { include "chart.name" . } } + helm.sh/chart: { { include "chart.chart" . } } + app.kubernetes.io/instance: { { .Release.Name } } + app.kubernetes.io/managed-by: { { .Release.Service } } data: - server-block.conf: |- - # set upstream server - upstream lm_app { - # fail_timeout=0 means we always retry an upstream even if it failed - # to return a good HTTP response - server {{ include "chart.fullname" . }}-backend:8000 fail_timeout=0; + server-block.conf: |- + # set upstream server + upstream lm_app { + # fail_timeout=0 means we always retry an upstream even if it failed + # to return a good HTTP response + server {{ include "chart.fullname" . }}-backend:8000 fail_timeout=0; + } + + {{- if .Values.rateLimiting.zone.accounts.enabled }} + # Define Rate Limiting Zones + limit_req_zone $binary_remote_addr zone=api_accounts:{{ .Values.rateLimiting.zone.accounts.size }} rate={{ .Values.rateLimiting.zone.accounts.rate }}; + {{- end }} + + server { + listen 0.0.0.0:8080 default_server; + + # set the correct host(s) for your site + server_name localhost; + + #ssl_certificate /nginx/certs/lm.crt; + #ssl_certificate_key /nginx/certs/lm.key; + + # force HTTP traffic to HTTPS + error_page 497 https://$http_host$request_uri; + + # define error pages + error_page 404 /error/404; + error_page 429 /error/429; + error_page 500 /error/500; + error_page 502 /error/502; + + # location for error pages + location ~ ^/error { + # disable redirects + proxy_redirect off; + + # rewrite headers + proxy_pass_header Server; + proxy_set_header X-Real-IP $http_x_forwarded_for; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header Host $http_host; + proxy_set_header Cookie $http_cookie; + + # set uppstream + proxy_pass https://lm_app; } - {{- if .Values.rateLimiting.zone.accounts.enabled }} - # Define Rate Limiting Zones - limit_req_zone $binary_remote_addr zone=api_accounts:{{ .Values.rateLimiting.zone.accounts.size }} rate={{ .Values.rateLimiting.zone.accounts.rate }}; - {{- end }} - - server { - listen 0.0.0.0:8080 default_server; - client_max_body_size 4G; - # set the correct host(s) for your site - server_name localhost; - keepalive_timeout 60; - - #ssl_certificate /nginx/certs/lm.crt; - #ssl_certificate_key /nginx/certs/lm.key; - - # force HTTP traffic to HTTPS - error_page 497 https://$http_host$request_uri; - - # define error pages - error_page 404 /error/404; - error_page 429 /error/429; - error_page 500 /error/500; - error_page 502 /error/502; - - # location for error pages - location ~ ^/error { - # disable redirects - proxy_redirect off; - - # rewrite headers - proxy_pass_header Server; - proxy_set_header X-Real-IP $http_x_forwarded_for; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Scheme $scheme; - proxy_set_header Host $http_host; - proxy_set_header Cookie $http_cookie; - - # various proxy settings - proxy_connect_timeout 600; - proxy_read_timeout 600; - proxy_send_timeout 600; - #proxy_intercept_errors on; - - # set uppstream - proxy_pass https://lm_app; - } - - # set static files location - location /static/ { - root /app/lifemonitor; - } - - # if the path matches to root, redirect to the account page - location = / { - return 301 https://{{ .Values.externalServerName }}/account/; - } - - location ~ ^/account { - # disable redirects - proxy_redirect off; - - # rewrite headers - proxy_pass_header Server; - proxy_set_header X-Real-IP $http_x_forwarded_for; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Scheme $scheme; - proxy_set_header Host $http_host; - proxy_set_header Cookie $http_cookie; - - # various proxy settings - proxy_connect_timeout 600; - proxy_read_timeout 600; - proxy_send_timeout 600; - #proxy_intercept_errors on; - - # set uppstream - proxy_pass https://lm_app; - - {{ include "lifemonitor.api.rateLimiting" . | indent 12 }} - } - - # set proxy location - location / { - #resolver 127.0.0.11 ipv6=off valid=30s; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $http_host; - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass https://lm_app; - } + # set static files location + location /static/ { + root /app/lifemonitor; } + + # if the path matches to root, redirect to the account page + location = / { + return 301 https://{{ .Values.externalServerName }}/account/; + } + + location ~ ^/account { + # disable redirects + proxy_redirect off; + + # rewrite headers + proxy_pass_header Server; + proxy_set_header X-Real-IP $http_x_forwarded_for; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header Host $http_host; + proxy_set_header Cookie $http_cookie; + + # set uppstream + proxy_pass https://lm_app; + + {{ include "lifemonitor.api.rateLimiting" . | indent 12 }} + } + + # set proxy location + location / { + #resolver 127.0.0.11 ipv6=off valid=30s; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + proxy_pass https://lm_app; + } + } + + nginx.conf: |- + + # logs + pid /var/log/nginx/nginx.pid; + error_log /var/log/nginx/nginx.error.log warn; + + events { + worker_connections 1024; + } + + http { + + include mime.types; + + default_type application/octet-stream; + + # Enables or disables the use of underscores in client request header fields. + # When the use of underscores is disabled, request header fields whose names contain underscores are marked as invalid and become subject to the ignore_invalid_headers directive. + # underscores_in_headers off; + + proxy_headers_hash_max_size 512; + proxy_headers_hash_bucket_size 128; + + # Configure Log files + # access_log /var/log/nginx/access.log custom_format; + error_log /var/log/nginx/error.log warn; + + # See Move default writable paths to a dedicated directory (#119) + # https://github.com/openresty/docker-openresty/issues/119 + client_body_temp_path /var/run/nginx/nginx-client-body; + proxy_temp_path /var/run/nginx/nginx-proxy; + fastcgi_temp_path /var/run/nginx/nginx-fastcgi; + uwsgi_temp_path /var/run/nginx/nginx-uwsgi; + scgi_temp_path /var/run/nginx/nginx-scgi; + + # Increase the buffer size + proxy_buffers 8 16k; + proxy_buffer_size 32k; + + # various proxy settings + proxy_connect_timeout 180s; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + keepalive_timeout 180s; + + fastcgi_send_timeout 180s; + fastcgi_read_timeout 180s; + + sendfile on; + #tcp_nopush on; + + #gzip on; + + include /etc/nginx/conf.d/*.conf; + + # Don't reveal OpenResty version to clients. + # server_tokens off; + } diff --git a/k8s/templates/settings.secret.yaml b/k8s/templates/settings.secret.yaml index 7a0e00d9f..5bc67cac6 100644 --- a/k8s/templates/settings.secret.yaml +++ b/k8s/templates/settings.secret.yaml @@ -101,8 +101,14 @@ stringData: LIFEMONITOR_ADMIN_PASSWORD={{ .Values.lifemonitor.administrator.password }} # Gunicorn settings - GUNICORN_WORKERS=1 - GUNICORN_THREADS=2 + GUNICORN_WORKERS={{ .Values.lifemonitor.gunicorn.workers | default 2 }} + GUNICORN_THREADS={{ .Values.lifemonitor.gunicorn.threads | default 4 }} + GUNICORN_WORKER_CONNECTIONS={{ .Values.lifemonitor.gunicorn.worker_connections | default 1000 }} + GUNICORN_MAX_REQUESTS={{ .Values.lifemonitor.gunicorn.max_requests | default 0 }} + GUNICORN_MAX_REQUESTS_JITTER={{ .Values.lifemonitor.gunicorn.max_requests_jitter | default 0 }} + GUNICORN_TIMEOUT={{ .Values.lifemonitor.gunicorn.timeout | default 30 }} + GUNICORN_GRACEFUL_TIMEOUT={{ .Values.lifemonitor.gunicorn.graceful_timeout | default 30 }} + GUNICORN_KEEP_ALIVE={{ .Values.lifemonitor.gunicorn.keep_alive | default 2 }} # Set a warning message (displayed in the login screen and the user's profile page) WARNING_MESSAGE={{- .Values.lifemonitor.warning_message | default "" }} diff --git a/k8s/values.yaml b/k8s/values.yaml index b9052247d..35636f224 100644 --- a/k8s/values.yaml +++ b/k8s/values.yaml @@ -234,6 +234,27 @@ lifemonitor: cpu: 0.5 memory: 1024Mi + # gunicon settings + gunicorn: + # The number of worker threads for handling requests. + # A positive integer generally in the 2-4 x $(NUM_CORES) range. + # You’ll want to vary this a bit to find the best for your particular application’s work load. + workers: 2 + # The number of worker processes for handling requests. + # A positive integer generally in the 2-4 x $(NUM_CORES) range. + # You’ll want to vary this a bit to find the best for your particular application’s work load. + threads: 1 + # The maximum number of simultaneous clients. + worker_connections: 1000 + # The maximum number of requests a worker will process before restarting. + max_requests: 0 # (0 = unlimited) + # The maximum jitter to add to the max_requests setting. + max_requests_jitter: 0 # (0 = no jitter) + # Workers silent for more than this many seconds are killed and restarted. + timeout: 30 + # The number of seconds to wait for requests on a Keep-Alive connection. + keepalive: 2 + # configure resources for the init containers initContainers: initBackend: diff --git a/lifemonitor/api/controllers.py b/lifemonitor/api/controllers.py index c0bc32c67..16d681766 100644 --- a/lifemonitor/api/controllers.py +++ b/lifemonitor/api/controllers.py @@ -504,6 +504,8 @@ def process_workflows_post(body, _registry=None, _submitter_id=None, return lm_exceptions.report_problem(403, "Forbidden", extra_info={"exception": str(e)}, detail=messages.not_authorized_registry_access.format(registry.name) if registry else messages.not_authorized_workflow_access) + except lm_exceptions.ROCrateNotFoundException as e: + return lm_exceptions.report_problem(404, "RO-Crate not found", detail=str(e)) except lm_exceptions.WorkflowVersionConflictException: return lm_exceptions.report_problem(409, "Workflow version conflict", detail=messages.workflow_version_conflict diff --git a/lifemonitor/api/models/issues/general/lm.py b/lifemonitor/api/models/issues/general/lm.py index 3e219d286..1d029b789 100644 --- a/lifemonitor/api/models/issues/general/lm.py +++ b/lifemonitor/api/models/issues/general/lm.py @@ -22,10 +22,12 @@ import logging -from lifemonitor.utils import get_validation_schema_url from lifemonitor.api.models.issues import IssueMessage, WorkflowRepositoryIssue +from lifemonitor.api.models.issues.general.repo_layout import \ + GitRepositoryWithoutMainBranch from lifemonitor.api.models.repositories import WorkflowRepository from lifemonitor.schemas.validators import ValidationError, ValidationResult +from lifemonitor.utils import get_validation_schema_url # set module level logger logger = logging.getLogger(__name__) @@ -36,6 +38,7 @@ class MissingLMConfigFile(WorkflowRepositoryIssue): description = "No lifemonitor.yaml configuration file found on this repository.
"\ "The lifemonitor.yaml should be placed on the root of this repository." labels = ['lifemonitor'] + depends_on = [GitRepositoryWithoutMainBranch] def check(self, repo: WorkflowRepository) -> bool: if repo.config is None: diff --git a/lifemonitor/api/models/issues/general/repo_layout.py b/lifemonitor/api/models/issues/general/repo_layout.py index 98624d045..556ffbb64 100644 --- a/lifemonitor/api/models/issues/general/repo_layout.py +++ b/lifemonitor/api/models/issues/general/repo_layout.py @@ -24,15 +24,34 @@ from lifemonitor.api.models.issues import WorkflowRepositoryIssue from lifemonitor.api.models.repositories import WorkflowRepository +from lifemonitor.api.models.repositories.local import \ + LocalGitWorkflowRepository # set module level logger logger = logging.getLogger(__name__) +class GitRepositoryWithoutMainBranch(WorkflowRepositoryIssue): + name = "Repository without main branch" + description = "This repository does not have a main branch." + labels = ['best-practices'] + + def check(self, repo: WorkflowRepository) -> bool: + """ + If the repository is a Git repository, check if it has a main branch. + """ + if not LocalGitWorkflowRepository.is_git_repo(repo.local_path): + return False + git_repo = LocalGitWorkflowRepository(repo.local_path) + logger.debug("Local Git repository: %r - branches: %r", git_repo, git_repo.heads) + return git_repo.heads is None or len(git_repo.heads) == 0 + + class RepositoryNotInitialised(WorkflowRepositoryIssue): name = "Repository not intialised" description = "No workflow and crate metadata found on this repository." labels = ['best-practices'] + depends_on = [GitRepositoryWithoutMainBranch] def check(self, repo: WorkflowRepository) -> bool: return repo.find_workflow() is None and repo.metadata is None diff --git a/lifemonitor/api/models/registries/registry.py b/lifemonitor/api/models/registries/registry.py index f5f8ca12e..67f3ce665 100644 --- a/lifemonitor/api/models/registries/registry.py +++ b/lifemonitor/api/models/registries/registry.py @@ -227,6 +227,8 @@ def _requester(self, user, method: str, *args, **kwargs): errors.append(str(e)) if response.status_code == 401 or response.status_code == 403: raise lm_exceptions.NotAuthorizedException(details=response.content) + if response.status_code == 404: + raise lm_exceptions.ROCrateNotFoundException(details=response.content, resource=response.url) raise lm_exceptions.LifeMonitorException(errors=[str(e) for e in errors]) def get_index(self, user: auth_models.User) -> List[RegistryWorkflow]: diff --git a/lifemonitor/api/models/repositories/base.py b/lifemonitor/api/models/repositories/base.py index 45f39105b..9d80f25a2 100644 --- a/lifemonitor/api/models/repositories/base.py +++ b/lifemonitor/api/models/repositories/base.py @@ -267,7 +267,7 @@ def generate_config(self, ignore_existing=False, def write_zip(self, target_path: str): if not self.metadata: - raise IllegalStateException(detail="Missing RO Crate metadata") + raise IllegalStateException(detail="Missing RO-Crate metadata") return self.metadata.write_zip(target_path) def write(self, target_path: str, overwrite: bool = False) -> None: diff --git a/lifemonitor/api/models/repositories/github.py b/lifemonitor/api/models/repositories/github.py index 130a2de0e..0d7a15a62 100644 --- a/lifemonitor/api/models/repositories/github.py +++ b/lifemonitor/api/models/repositories/github.py @@ -382,9 +382,9 @@ def __del__(self): def cleanup(self) -> None: logger.debug("Repository cleanup") if getattr(self, "_local_repo", None): - local_repo_path = self.local_repo.local_path + local_repo_path = self._local_repo.local_path del self._local_repo - logger.debug("Removing temp folder %r of %r", self.local_path, self) + logger.debug("Removing temp folder %r of %r", self._local_path, self) shutil.rmtree(local_repo_path, ignore_errors=True) self._local_repo = None diff --git a/lifemonitor/api/models/repositories/local.py b/lifemonitor/api/models/repositories/local.py index 6a8e9d2a5..b594868ab 100644 --- a/lifemonitor/api/models/repositories/local.py +++ b/lifemonitor/api/models/repositories/local.py @@ -301,7 +301,8 @@ def __init__(self, self._remote_repo_info = None try: self._remote_repo_info = RemoteGitRepoInfo.parse(self._git_repo.remotes.origin.url) - except git.exc.GitCommandError as e: + except (git.exc.GitCommandError, AttributeError) as e: + logger.warning("Unable to parse remote repository info: %s", e) if logger.isEnabledFor(logging.DEBUG): logger.exception(e) @@ -309,6 +310,14 @@ def __init__(self, def main_branch(self) -> str: return self._git_repo.active_branch.name + @property + def remotes(self) -> List[str]: + return [r.name for r in self._git_repo.remotes] + + @property + def heads(self) -> List[str]: + return [h.name for h in self._git_repo.heads] + @property def owner(self) -> str: return super().owner or \ diff --git a/lifemonitor/api/models/workflows.py b/lifemonitor/api/models/workflows.py index a27b189ac..7dc723088 100644 --- a/lifemonitor/api/models/workflows.py +++ b/lifemonitor/api/models/workflows.py @@ -22,12 +22,11 @@ import logging from typing import List, Optional, Set, Union +from uuid import UUID from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased -from sqlalchemy.orm.collections import (MappedCollection, - attribute_mapped_collection, - collection) +from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.sql.expression import true @@ -183,22 +182,6 @@ def get_hosted_workflows_by_uri(cls, hosting_service: HostingService, uri: str, return query.all() -class WorkflowVersionCollection(MappedCollection): - - def __init__(self) -> None: - super().__init__(lambda wv: wv.workflow.uuid) - - @collection.internally_instrumented - def __setitem__(self, key, value, _sa_initiator=None): - current_value = self.get(key, set()) - current_value.add(value) - super(WorkflowVersionCollection, self).__setitem__(key, current_value, _sa_initiator) - - @collection.internally_instrumented - def __delitem__(self, key, _sa_initiator=None): - super(WorkflowVersionCollection, self).__delitem__(key, _sa_initiator) - - class WorkflowVersion(ROCrate): id = db.Column(db.Integer, db.ForeignKey(ROCrate.id), primary_key=True) submitter_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=True) @@ -211,8 +194,7 @@ class WorkflowVersion(ROCrate): test_suites = db.relationship("TestSuite", back_populates="workflow_version", cascade="all, delete") submitter = db.relationship("User", uselist=False, - backref=db.backref("workflows", cascade="all, delete-orphan", - collection_class=WorkflowVersionCollection)) + backref=db.backref("workflow_versions", cascade="all, delete-orphan")) __mapper_args__ = { 'polymorphic_identity': 'workflow_version' @@ -439,3 +421,23 @@ def get_hosted_workflow_versions_by_uri(cls, hosting_service: HostingService, ur .join(WorkflowVersion, WorkflowVersion.hosting_service_id == hosting_service.id)\ .filter(HostingService.uuid == lm_utils.uuid_param(hosting_service.uuid))\ .filter(WorkflowVersion.uri == uri).all() + + +def __get_user_workflows_map__(user: User) -> dict[UUID, Set[WorkflowVersion]]: + ''' + utility function to get the workflows of a user from the list of workflow versions + submitted by the user + ''' + workflows = {} + for v in user.workflow_versions: + w_set = workflows.get(v.workflow.uuid, None) + if w_set is None: + workflows[v.workflow.uuid] = {v} + else: + w_set.add(v) + + return workflows + + +# augmentg the User class with the "workflow" property +User.workflows = property(lambda self: __get_user_workflows_map__(self)) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 45de399af..73862da6d 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -40,7 +40,7 @@ from .forms import (EmailForm, LoginForm, NotificationsForm, Oauth2ClientForm, RegisterForm, SetPasswordForm) from .models import db -from .oauth2.client.services import (get_current_user_identity, get_providers, +from .oauth2.client.services import (get_current_user_identity, get_providers, merge_users, save_current_user_identity) from .oauth2.server.services import server from .services import (authorized, current_registry, current_user, @@ -515,29 +515,46 @@ def disable_registry_sync(): @blueprint.route("/merge", methods=("GET", "POST")) @login_required def merge(): + # get the username and provider from the request username = request.args.get("username") provider = request.args.get("provider") - flash(f"Your {provider} identity is already linked to the username " - f"{username} and cannot be merged to {current_user.username}", - category="warning") - return redirect(url_for('auth.profile')) - # form = LoginForm(data={ - # "username": username, - # "provider": provider}) - # if form.validate_on_submit(): - # user = form.get_user() - # if user: - # if user != current_user: - # merge_users(current_user, user, request.args.get("provider")) - # flash( - # "User {username} has been merged into your account".format( - # username=user.username - # ) - # ) - # return redirect(url_for("auth.index")) - # else: - # form.username.errors.append("Cannot merge with yourself") - # return render_template("auth/merge.j2", form=form) + + # Uncomment to disable the merge feature + # flash(f"Your {p>rovider} identity is already linked to the username " + # f"{username} and cannot be merged to {current_user.username}", + # category="warning") + # return redirect(url_for('auth.profile')) + + # Check the authenticity of the identity before merging + form = LoginForm(data={ + "username": username, + "provider": provider}) + if form.validate_on_submit(): + user = form.get_user() + if user: + # check if the user is the same as the current user + if user == current_user: + flash("Cannot merge with yourself", category="warning") + form.username.errors.append("Cannot merge with yourself") + else: + # merge the users + resulting_user = merge_users(current_user, user, request.args.get("provider")) + logger.debug("User obtained by the merging process: %r", resulting_user) + logout_user() + # login the resulting user + login_user(resulting_user) + # redirect to the profile page with a flash message + flash( + "User {username} has been merged into your account".format( + username=resulting_user.username + ), category="success" + ) + return profile() + # render the merge page + return render_template("auth/merge.j2", form=form, identity={ + "username": username, + "provider": provider + }) @blueprint.route("/create_apikey", methods=("POST",)) diff --git a/lifemonitor/auth/forms.py b/lifemonitor/auth/forms.py index 9a819526f..a59d8053d 100644 --- a/lifemonitor/auth/forms.py +++ b/lifemonitor/auth/forms.py @@ -59,7 +59,10 @@ def get_user(self): if not user: self.username.errors.append("Username not found") return None - if not user.verify_password(self.password.data): + if not user.has_password: + self.password.errors.append("The user has no password set") + return None + if not self.password.data or not user.verify_password(self.password.data): self.password.errors.append("Invalid password") return None return user diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index ec4283812..a3a2d28c7 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -105,7 +105,7 @@ def get_authorization(self, resource: Resource): @property def current_identity(self): from .services import current_registry, current_user - if not current_user.is_anonymous: + if not current_user.is_anonymous and current_user.id == self.id: return self.oauth_identity if current_registry: for p, i in self.oauth_identity.items(): @@ -130,6 +130,8 @@ def has_password(self): return bool(self.password_hash) def verify_password(self, password): + if not self.password_hash: + return False return check_password_hash(self.password_hash, password) def _generate_random_code(self, chars=string.ascii_uppercase + string.digits): diff --git a/lifemonitor/auth/oauth2/client/services.py b/lifemonitor/auth/oauth2/client/services.py index 7eaa66aeb..bc0869c90 100644 --- a/lifemonitor/auth/oauth2/client/services.py +++ b/lifemonitor/auth/oauth2/client/services.py @@ -23,6 +23,7 @@ import logging from flask import current_app, session + from lifemonitor import exceptions from lifemonitor.db import db, db_initialized @@ -78,21 +79,86 @@ def config_oauth2_registry(app, providers=None): logger.debug("OAuth2 registry configured!") -def merge_users(merge_from: User, merge_into: User, provider: str): +def merge_users(merge_from: User, merge_into: User, provider: str = None): assert merge_into != merge_from - logger.debug("Trying to merge %r, %r, %r", merge_into, merge_from, provider) - for identity in list(merge_from.oauth_identity.values()): - identity.user = merge_into - db.session.add(identity) - # TODO: Move all oauth clients to the new user - for client in list(merge_from.clients): - client.user = merge_into - db.session.add(client) - # TODO: Check for other links to move to the new user - # e.g., tokens, workflows, tests, .... - db.session.delete(merge_from) - db.session.commit() - return merge_into + try: + # start a new transaction + with db.session.no_autoflush: + logger.debug("Trying to merge user %r into %r (provider: %r)", merge_from, merge_into, provider) + # make a copy of the workflow versions submitted by the "merge_from" user + workflow_versions = list(merge_from.workflow_versions) + # update the submitter of the workflow versions + for v in workflow_versions: + v.submitter = merge_into + db.session.add(v) + # update suites submitted by the "merge_from" user + for s in v.test_suites: + if s.submitter == merge_from: + s.submitter = merge_into + db.session.add(s) + # update test instances submitted by the "merge_from" user + for i in s.test_instances: + if i.submitter == merge_from: + i.submitter = merge_into + db.session.add(i) + + # update all the remaining permissions + for permission in list(merge_from.permissions): + permission.user = merge_into + db.session.add(permission) + + # move all the authorizations granted to the "merge_from" user to the "merge_into" user + for auth in list(merge_from.authorizations): + auth.user = merge_into + db.session.add(auth) + + # move all the notification of the user "merge_from" to the user "merge_into" + merge_into_notification_ids = [un.notification.id for un in merge_into.notifications] + for user_notification in list(merge_from.notifications): + if user_notification.notification.id not in merge_into_notification_ids: + user_notification.user = merge_into + db.session.add(user_notification) + + # move all the subscriptions of the user "merge_from" to the user "merge_into" + for subscription in list(merge_from.subscriptions): + subscription.user = merge_into + db.session.add(subscription) + + # move all the api keys of the user "merge_from" to the user "merge_into" + for api_key in list(merge_from.api_keys): + api_key.user = merge_into + db.session.add(api_key) + + # move all the oauth identities of the user "merge_from" to the user "merge_into" + for identity in list(merge_from.oauth_identity): + identity.user = merge_into + db.session.add(identity) + + # move all the clients of the user "merge_from" to the user "merge_into" + for client in list(merge_from.clients): + client.user = merge_into + db.session.add(client) + + # remove the "merge_from" user + db.session.delete(merge_from) + + # commit the changes + db.session.add(merge_into) + db.session.commit() + + # remove the "merge_from" user + db.session.delete(merge_from) + + # flush the changes + db.session.flush() + # remove the "merge_from" user + return merge_into + except Exception as e: + logger.error("Unable to merge users: %r", e) + if logger.isEnabledFor(logging.DEBUG): + logger.exception(e) + db.session.rollback() + raise exceptions.LifeMonitorException(title="Unable to merge users") from e def save_current_user_identity(identity: OAuthIdentity): diff --git a/lifemonitor/auth/templates/auth/merge.j2 b/lifemonitor/auth/templates/auth/merge.j2 index 5406e5e55..43708be94 100644 --- a/lifemonitor/auth/templates/auth/merge.j2 +++ b/lifemonitor/auth/templates/auth/merge.j2 @@ -1,15 +1,60 @@ {% extends 'base.j2' %} {% import 'macros.j2' as macros %} + +{% block body_class %} login-page {% endblock %} +{% block body_style %} height: auto; {% endblock %} + {% block body %} -

Merge Account

-

If you want to merge another account into this one, log in to that account here. That account must have a password set.

-
- {{ form.hidden_tag() }} - {{ macros.render_field(form.username) }} - {{ macros.render_field(form.password) }} -
- + {% endblock %} diff --git a/lifemonitor/errors.py b/lifemonitor/errors.py index f42442a0b..7de4d74fc 100644 --- a/lifemonitor/errors.py +++ b/lifemonitor/errors.py @@ -24,8 +24,6 @@ from flask import Blueprint, escape, render_template, request, url_for -from lifemonitor.utils import validate_url - # Config a module level logger logger = logging.getLogger(__name__) @@ -55,11 +53,23 @@ def parametric_page(): return handle_500() +def handle_error(e: Exception): + status = getattr(e, 'status', 500) + try: + handler = getattr(error_handlers, f"handle_{status}") + logger.debug(f"Handling error code: {status}") + return handler(e) + except ValueError as e: + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"Error handling error code: {e}") + return handle_500(e) + + @blueprint.route("/400") def handle_400(e: Exception = None, description: str = None): - return handle_error( + return __handle_error__( { - "title": "LifeMonitor: Page not found", + "title": getattr(e, 'title', None) or "LifeMonitor: Page not found", "code": "404", "description": description if description else str(e) if e and logger.isEnabledFor(logging.DEBUG) @@ -72,14 +82,15 @@ def handle_400(e: Exception = None, description: str = None): def handle_404(e: Exception = None): resource = request.args.get("resource", None, type=str) logger.debug(f"Resource not found: {resource}") + from lifemonitor.utils import validate_url if resource and not validate_url(resource): logger.error(f"Invalid URL: {resource}") return handle_400(description="Invalid URL") - return handle_error( + return __handle_error__( { - "title": "LifeMonitor: Page not found", + "title": getattr(e, 'title', None) or "LifeMonitor: Page not found", "code": "404", - "description": str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Page not found", "resource": resource, @@ -91,13 +102,14 @@ def handle_404(e: Exception = None): def handle_405(e: Exception = None): resource = request.args.get("resource", None, type=str) logger.debug(f"Method not allowed for resource {resource}") + from lifemonitor.utils import validate_url if not validate_url(resource): return handle_400(decription="Invalid URL") - return handle_error( + return __handle_error__( { - "title": "LifeMonitor: Method not allowed", + "title": getattr(e, 'title', None) or "LifeMonitor: Method not allowed", "code": "404", - "description": str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Method not allowed for this resource", "resource": escape(resource), @@ -107,11 +119,11 @@ def handle_405(e: Exception = None): @blueprint.route("/429") def handle_429(e: Exception = None): - return handle_error( + return __handle_error__( { - "title": "LifeMonitor: API rate limit exceeded", + "title": getattr(e, 'title', None) or "LifeMonitor: API rate limit exceeded", "code": "429", - "description": str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "API rate limit exceeded", } @@ -120,11 +132,11 @@ def handle_429(e: Exception = None): @blueprint.route("/500") def handle_500(e: Exception = None): - return handle_error( + return __handle_error__( { - "title": "LifeMonitor: Internal Server Error", + "title": getattr(e, 'title', None) or "LifeMonitor: Internal Server Error", "code": "500", - "description": str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Internal Server Error: the server encountered a temporary error and could not complete your request", } @@ -133,22 +145,25 @@ def handle_500(e: Exception = None): @blueprint.route("/502") def handle_502(e: Exception = None): - return handle_error( + return __handle_error__( { - "title": "LifeMonitor: Bad Gateway", + "title": getattr(e, 'title', None) or "LifeMonitor: Bad Gateway", "code": "502", - "description": str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Internal Server Error: the server encountered a temporary error and could not complete your request", } ) -def handle_error(error: Dict[str, str]): +def __handle_error__(error: Dict[str, str]): back_url = request.args.get("back_url", url_for("auth.profile")) # parse Accept header accept = request.headers.get("Accept", "text/html") - if "application/json" in accept: + content_type = request.headers.get("Content-Type") + if "application/json" == accept \ + or 'application/json' == content_type \ + or 'application/problem+json' == content_type: # return error as JSON return error, error.get("code", 500) try: diff --git a/lifemonitor/exceptions.py b/lifemonitor/exceptions.py index dfab2f6f9..e186b9cee 100644 --- a/lifemonitor/exceptions.py +++ b/lifemonitor/exceptions.py @@ -21,7 +21,6 @@ import logging import connexion - from flask import Response, request from werkzeug.exceptions import HTTPException @@ -158,15 +157,27 @@ def __init__(self, detail=None, class NotValidROCrateException(LifeMonitorException): - def __init__(self, detail="Not valid RO Crate", + def __init__(self, detail="Not valid RO-Crate", type="about:blank", status=400, instance=None, **kwargs): super().__init__(title="Bad request", detail=detail, status=status, **kwargs) +class ROCrateNotFoundException(LifeMonitorException): + + def __init__(self, detail="RO-Crate not found", + type="about:blank", status=404, resource=None, **kwargs): + super().__init__(title="Bad request", + detail=detail, status=status, **kwargs) + self.resource = resource + + def __str__(self): + return f"Unable to find the RO-Crate {self.resource}" + + class DecodeROCrateException(LifeMonitorException): - def __init__(self, detail="Unable to decode RO Crate", + def __init__(self, detail="Unable to decode RO-Crate", type="about:blank", status=400, instance=None, **kwargs): super().__init__(title="Bad request", detail=detail, status=status, **kwargs) @@ -216,9 +227,12 @@ def handle_exception(e: Exception): if logger.isEnabledFor(logging.DEBUG): logger.exception(e) if isinstance(e, LifeMonitorException): + from .errors import handle_error + if request.accept_mimetypes.best == "text/html": + return handle_error(e) return Response(response=e.to_json(), status=e.status, - mimetype="application/problem+json") + mimetype=request.accept_mimetypes.best) if isinstance(e, HTTPException): return report_problem(status=e.code, title=e.__class__.__name__, @@ -243,6 +257,9 @@ def report_problem(status, title, detail=None, type=None, instance=None, extra_i """ Returns a `Problem Details `_ error response. """ + if request.accept_mimetypes.best == "text/html": + from .errors import handle_error + return handle_error(LifeMonitorException(title=title, detail=detail, status=status)) if not type: type = 'about:blank' diff --git a/lifemonitor/lang/messages.py b/lifemonitor/lang/messages.py index f91d3a430..2c93ec989 100644 --- a/lifemonitor/lang/messages.py +++ b/lifemonitor/lang/messages.py @@ -31,8 +31,10 @@ not_authorized_registry_access = "User not authorized to access the registry '{}'" not_authorized_workflow_access = "User not authorized to get workflow data" input_data_missing = "One or more input data are missing" -decode_ro_crate_error = "Unable to decode the RO Crate: it should be encoded using base64" -invalid_ro_crate = "RO Crate processing exception" +decode_ro_crate_error = "Unable to decode the RO-Crate: it should be encoded using base64" +invalid_ro_crate = "RO-Crate processing exception" +ro_crate_not_found = "RO-Crate not found" +registry_ro_crate_not_found = "Registry RO-Crate not found" workflow_not_found = "Workflow '{}' not found" workflow_version_not_found = "Workflow '{}' (ver.{}) not found" workflow_version_conflict = "Workflow '{}' (ver.{}) already exists" diff --git a/lifemonitor/redis.py b/lifemonitor/redis.py index 29e4453b8..8bb3ae8de 100644 --- a/lifemonitor/redis.py +++ b/lifemonitor/redis.py @@ -18,6 +18,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import logging + +import redis_lock from flask import Flask from redis import Redis @@ -37,4 +40,17 @@ def init(app: Flask) -> Redis: port=int(app.config.get("REDIS_PORT_NUMBER", 6379)), password=app.config.get("REDIS_PASSWORD", "foobar"), db=0) + + # reconfigure the logging level for the redis_lock library + redis_lock_logger_level = logging.WARNING + if app.config.get("DEBUG", False): + redis_lock_logger_level = logging.DEBUG + redis_lock.logger_for_acquire.setLevel(redis_lock_logger_level) + redis_lock.logger_for_release.setLevel(redis_lock_logger_level) + redis_lock.logger_for_acquire.setLevel(redis_lock_logger_level) + redis_lock.logger_for_refresh_thread.setLevel(redis_lock_logger_level) + redis_lock.logger_for_refresh_start.setLevel(redis_lock_logger_level) + redis_lock.logger_for_refresh_shutdown.setLevel(redis_lock_logger_level) + redis_lock.logger_for_refresh_exit.setLevel(redis_lock_logger_level) + redis_lock.logger_for_release.setLevel(redis_lock_logger_level) return __redis__ diff --git a/lifemonitor/static/oauth-receiver.html b/lifemonitor/static/oauth-receiver.html index f64b7c143..6042b041c 100644 --- a/lifemonitor/static/oauth-receiver.html +++ b/lifemonitor/static/oauth-receiver.html @@ -1,3 +1,25 @@ + + diff --git a/lifemonitor/static/specs/apidocs.html b/lifemonitor/static/specs/apidocs.html index e65f27322..a7c847bc6 100644 --- a/lifemonitor/static/specs/apidocs.html +++ b/lifemonitor/static/specs/apidocs.html @@ -1,3 +1,25 @@ + + diff --git a/lifemonitor/static/src/config/pkg-plugins.sh b/lifemonitor/static/src/config/pkg-plugins.sh index a9f634195..1d3706970 100755 --- a/lifemonitor/static/src/config/pkg-plugins.sh +++ b/lifemonitor/static/src/config/pkg-plugins.sh @@ -1,5 +1,26 @@ #!/bin/bash +# +# Copyright (c) 2020-2024 CRS4 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + # set target target_path="../dist/plugins" diff --git a/lifemonitor/static/src/config/postcss.config.js b/lifemonitor/static/src/config/postcss.config.js index 157291ffd..66db3593a 100644 --- a/lifemonitor/static/src/config/postcss.config.js +++ b/lifemonitor/static/src/config/postcss.config.js @@ -1,3 +1,25 @@ +/* +Copyright (c) 2020-2024 CRS4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + 'use strict' module.exports = (ctx) => ({ diff --git a/lifemonitor/static/src/config/rollup.config.js b/lifemonitor/static/src/config/rollup.config.js index 10c63e747..4e7273655 100644 --- a/lifemonitor/static/src/config/rollup.config.js +++ b/lifemonitor/static/src/config/rollup.config.js @@ -1,3 +1,25 @@ +/* +Copyright (c) 2020-2024 CRS4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + import { babel } from '@rollup/plugin-babel'; const pkg = require('../package') diff --git a/lifemonitor/static/src/js/lifemonitor.js b/lifemonitor/static/src/js/lifemonitor.js index bec3feee9..fbf9fd966 100644 --- a/lifemonitor/static/src/js/lifemonitor.js +++ b/lifemonitor/static/src/js/lifemonitor.js @@ -1,3 +1,25 @@ +/* +Copyright (c) 2020-2024 CRS4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + // Copy function function copyToClipboard(value, message) { $(function () { diff --git a/lifemonitor/static/src/package.json b/lifemonitor/static/src/package.json index 640616d23..9fac52eb3 100644 --- a/lifemonitor/static/src/package.json +++ b/lifemonitor/static/src/package.json @@ -1,7 +1,7 @@ { "name": "lifemonitor", "description": "Workflow Testing Service", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "author": "CRS4", "main": "../dist/js/lifemonitor.min.js", diff --git a/lifemonitor/utils.py b/lifemonitor/utils.py index c354c164d..d6b9187f2 100644 --- a/lifemonitor/utils.py +++ b/lifemonitor/utils.py @@ -515,7 +515,7 @@ def __enter__(self): # written into a local file and a local roc_link will be returned. logger.debug("Entering ROCrateLinkContext: %r", self.rocrate_or_link) if validate_url(self.rocrate_or_link): - logger.debug("RO Crate param is a link: %r", self.rocrate_or_link) + logger.debug("RO-Crate param is a link: %r", self.rocrate_or_link) return self.rocrate_or_link if self.rocrate_or_link: if os.path.isdir(self.rocrate_or_link) or os.path.isfile(self.rocrate_or_link): @@ -535,7 +535,7 @@ def __enter__(self): except Exception as e: logger.debug(e) raise lm_exceptions.DecodeROCrateException(detail=str(e)) - logger.debug("RO Crate link is undefined!!!") + logger.debug("RO-Crate link is undefined!!!") return None def __exit__(self, type, value, traceback): @@ -807,7 +807,8 @@ class RemoteGitRepoInfo(giturlparse.result.GitUrlParsed): def __init__(self, parsed_info): # fix for giturlparse: protocols are not parsed correctly - del parsed_info['protocols'] + if 'protocols' in parsed_info: + del parsed_info['protocols'] super().__init__(parsed_info) @property @@ -1070,6 +1071,7 @@ def pop(cls, default=None, skipValidation=False): # if the route is not defined as param, try to get it from the registry if route is None: registry = cls._get_route_registry() + logger.debug("Route registry: %r", registry) try: route = registry.pop() logger.debug("Route registry changed: %r", registry) @@ -1080,8 +1082,12 @@ def pop(cls, default=None, skipValidation=False): cls._save_route_registry(registry) if skipValidation: return route or default + # if the route is not defined, set the default route as next route + if not route: + route = default # validate the actual route try: + logger.debug("Validating route: %r", route) cls.validate_next_route_url(route) except ValidationError as e: logger.error(e) @@ -1108,11 +1114,13 @@ def validate_next_route_url(cls, url: str) -> bool: # check whether the URL is valid url_domain = None try: + logger.debug("Validating URL: %r", url) url_domain = get_netloc(url) except Exception as e: if logger.isEnabledFor(logging.DEBUG): logger.exception(e) # check whether a url domain has been extracted + logger.debug("URL domain: %r", url_domain) if url_domain is None: raise ValidationError("Invalid URL: unable to detect domain") # check if the URL domain matches the main domain of the back-end app diff --git a/lm b/lm index 94b3c391a..bb6c8d025 100755 --- a/lm +++ b/lm @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2020-2022 CRS4 +# Copyright (c) 2020-2024 CRS4 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/lm-admin b/lm-admin index 3da06d06b..e17407d66 100755 --- a/lm-admin +++ b/lm-admin @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2020-2022 CRS4 +# Copyright (c) 2020-2024 CRS4 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/lm-docker b/lm-docker index 4da938bad..ae9fb34d6 100755 --- a/lm-docker +++ b/lm-docker @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2020-2022 CRS4 +# Copyright (c) 2020-2024 CRS4 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/lm-metrics-server b/lm-metrics-server index 4b164ee9f..05153cbe6 100755 --- a/lm-metrics-server +++ b/lm-metrics-server @@ -1,4 +1,4 @@ -# Copyright (c) 2020-2022 CRS4 +# Copyright (c) 2020-2024 CRS4 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/settings.conf b/settings.conf index 6953728f9..d7c0dec47 100644 --- a/settings.conf +++ b/settings.conf @@ -61,6 +61,12 @@ POSTGRESQL_PASSWORD=foobar # Gunicorn settings GUNICORN_WORKERS=1 GUNICORN_THREADS=2 +GUNICORN_MAX_REQUESTS=0 +GUNICORN_MAX_REQUESTS_JITTER=0 +GUNICORN_WORKER_CONNECTIONS=1000 +GUNICORN_TIMEOUT=30 +GUNICORN_GRACEFUL_TIMEOUT=30 +GUNICORN_KEEP_ALIVE=2 # Dramatiq worker settings WORKER_PROCESSES=1 diff --git a/specs/api.yaml b/specs/api.yaml index bbed3a300..e93769681 100644 --- a/specs/api.yaml +++ b/specs/api.yaml @@ -3,7 +3,7 @@ openapi: "3.0.0" info: - version: "0.12.0" + version: "0.13.0" title: "Life Monitor API" description: | *Workflow sustainability service* @@ -18,7 +18,7 @@ info: servers: - url: / description: > - Version 0.12.0 of API. + Version 0.13.0 of API. tags: - name: GitHub Integration diff --git a/tests/unit/auth/models/test_users.py b/tests/unit/auth/models/test_users.py index 793a23099..a58dcbea2 100644 --- a/tests/unit/auth/models/test_users.py +++ b/tests/unit/auth/models/test_users.py @@ -19,20 +19,24 @@ # SOFTWARE. import logging -from lifemonitor.auth import serializers -from lifemonitor.auth.services import login_registry +from lifemonitor.auth import serializers +from lifemonitor.auth.models import User +from lifemonitor.auth.oauth2.client.services import get_current_user_identity +from lifemonitor.auth.services import login_registry, login_user logger = logging.getLogger() -def test_identity(app_client, user1, client_credentials_registry): +def test_identity_by_registry_credentials(app_client, user1, client_credentials_registry, user2): login_registry(client_credentials_registry) - user = user1['user'] + user: User = user1['user'] logger.debug(user) logger.debug(user.oauth_identity) + logger.debug("User1 current identity: %r", user.current_identity) + assert user.current_identity is not None, "Current identity should not be empty" identity = user.current_identity[client_credentials_registry.name] assert identity, \ @@ -47,6 +51,48 @@ def test_identity(app_client, user1, client_credentials_registry): assert serialization['identities'][client_credentials_registry.name]['provider']['name'] == client_credentials_registry.name, \ "Invalid provider" + # check current_identity + user2_obj = user2['user'] + logger.debug("User2 info: %r", user2) + assert user2_obj.current_identity is not None, "User2 should not be authenticated" + assert user2_obj.current_identity[client_credentials_registry.name].provider == client_credentials_registry.server_credentials, \ + "Unexpected identity provider" + assert user2_obj.current_identity[client_credentials_registry.name].user == user2_obj, \ + "Unexpected identity user" + + +def test_identity_by_user_credentials(app_client, user1, user2): + + user: User = user1['user'] + logger.debug(user) + logger.debug(user.oauth_identity) + + # check current_identity before login + assert user.current_identity is None, "Identity should be empty" + + # login user + login_user(user) + logger.debug("User1 current identity: %r", user.current_identity) + + # check current_identity after login + assert user.current_identity is not None, "Identity should not be empty" + + # check get current user identity + identity = get_current_user_identity() + logger.debug("Current user identity: %r", identity) + + user2_obj = user2['user'] + logger.debug(f"User2 Info: {user2}") + logger.debug(f"User2 Object: {user2_obj}") + + # check oauth identities of user2 + logger.debug(user2_obj.oauth_identity) + assert user2_obj.oauth_identity is not None, "Identity should not be empty" + + # check current_identity of user2 + logger.debug(f"User2 current identity: {user2_obj.current_identity}") + assert user2_obj.current_identity is None, "Identity of user2 should be empty" + def test_identity_unavailable(app_client, user1): user = user1['user']