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 %}
-
If you want to merge another account into this one, log in to that account here. That account must have a password set.
-