From e1c9d89f2eb33c6f21282d7f50c591fe8e752125 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 15 Jan 2024 10:49:06 +0100 Subject: [PATCH 01/46] chore: fix copyright information not updated --- docker/wss-entrypoint.sh | 2 +- lifemonitor/static/oauth-receiver.html | 22 +++++++++++++++++++ lifemonitor/static/specs/apidocs.html | 22 +++++++++++++++++++ lifemonitor/static/src/config/pkg-plugins.sh | 21 ++++++++++++++++++ .../static/src/config/postcss.config.js | 22 +++++++++++++++++++ .../static/src/config/rollup.config.js | 22 +++++++++++++++++++ lifemonitor/static/src/js/lifemonitor.js | 22 +++++++++++++++++++ lm | 2 +- lm-admin | 2 +- lm-docker | 2 +- lm-metrics-server | 2 +- 11 files changed, 136 insertions(+), 5 deletions(-) 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/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/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 From 082d5479fd694fedb5a062561054dd46328c07e5 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 16 Jan 2024 10:04:38 +0100 Subject: [PATCH 02/46] chore: more debug messages --- lifemonitor/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lifemonitor/utils.py b/lifemonitor/utils.py index c354c164d..6da8c02a2 100644 --- a/lifemonitor/utils.py +++ b/lifemonitor/utils.py @@ -1070,6 +1070,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) @@ -1082,6 +1083,7 @@ def pop(cls, default=None, skipValidation=False): return route or 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 +1110,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 From f34229e23a3d002147cc9e988ef117d33f4426ce Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 16 Jan 2024 10:06:56 +0100 Subject: [PATCH 03/46] fix: if the route is not defined, set the default route as next route --- lifemonitor/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lifemonitor/utils.py b/lifemonitor/utils.py index 6da8c02a2..fd9d67006 100644 --- a/lifemonitor/utils.py +++ b/lifemonitor/utils.py @@ -1081,6 +1081,9 @@ 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) From 9ccc4851bde0f19cb361582a361ec79ae095ab7a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 15 Sep 2023 10:19:03 +0200 Subject: [PATCH 04/46] fix(model): add missing check of user id --- lifemonitor/auth/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index ec4283812..cc9bd59c5 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(): From cb5a7cdc8c25398c0f4e77c20db4170a9497ecbf Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Fri, 15 Sep 2023 10:31:36 +0200 Subject: [PATCH 05/46] test(model): check user identity --- tests/unit/auth/models/test_users.py | 54 +++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) 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'] From 7d8416ac3084f737042aa87d168a0e56a9c7d8c2 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 14:23:23 +0200 Subject: [PATCH 06/46] refactor(model): rewrite backref user->workflows --- lifemonitor/api/models/workflows.py | 46 +++++++++++++++-------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/lifemonitor/api/models/workflows.py b/lifemonitor/api/models/workflows.py index a27b189ac..30f891987 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 import aliased 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)) From e4da2c998ba6c625111b89a4030d877ae2c3f741 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 14:53:02 +0200 Subject: [PATCH 07/46] feat(srv): rewrite fn to merge users accounts --- lifemonitor/auth/oauth2/client/services.py | 94 ++++++++++++++++++---- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/lifemonitor/auth/oauth2/client/services.py b/lifemonitor/auth/oauth2/client/services.py index 7eaa66aeb..60357bc10 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" + for notification in list(merge_from.notifications): + if merge_from in notification.users: + notification.users.remove(merge_from) + notification.users.append(merge_into) + db.session.add(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): From 068991715ee9153314bdee830fa84dc0ab9313e9 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 15:02:45 +0200 Subject: [PATCH 08/46] fix: wrong import --- lifemonitor/api/models/workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/api/models/workflows.py b/lifemonitor/api/models/workflows.py index 30f891987..7dc723088 100644 --- a/lifemonitor/api/models/workflows.py +++ b/lifemonitor/api/models/workflows.py @@ -24,8 +24,8 @@ from typing import List, Optional, Set, Union from uuid import UUID -from sqlalchemy import aliased from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import aliased from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.sql.expression import true From 5c2b63125c921a65b2d11fb08911c1c2405c6fd6 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 16:26:38 +0200 Subject: [PATCH 09/46] fix(model): fail fast when the password has not been set --- lifemonitor/auth/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index cc9bd59c5..a3a2d28c7 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -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): From f77c32dec443d968644479e4b03d65bdbbeae9ea Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 16:29:35 +0200 Subject: [PATCH 10/46] feat(form): more appropriate validation error --- lifemonitor/auth/forms.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 83fc800ed1d506694e4c68f178bbdbf5646d405b Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 18:09:56 +0200 Subject: [PATCH 11/46] fix(srv): avoid duplicated notifications for merged users --- lifemonitor/auth/oauth2/client/services.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lifemonitor/auth/oauth2/client/services.py b/lifemonitor/auth/oauth2/client/services.py index 60357bc10..bc0869c90 100644 --- a/lifemonitor/auth/oauth2/client/services.py +++ b/lifemonitor/auth/oauth2/client/services.py @@ -113,11 +113,11 @@ def merge_users(merge_from: User, merge_into: User, provider: str = None): db.session.add(auth) # move all the notification of the user "merge_from" to the user "merge_into" - for notification in list(merge_from.notifications): - if merge_from in notification.users: - notification.users.remove(merge_from) - notification.users.append(merge_into) - db.session.add(notification) + 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): From c9e34c7f51e4360531e6305418a2e2ab76c3e86b Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 18:27:09 +0200 Subject: [PATCH 12/46] feat(ctrl): enable feature to merge users --- lifemonitor/auth/controllers.py | 58 ++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 45de399af..6feaf6f33 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,43 @@ 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) @blueprint.route("/create_apikey", methods=("POST",)) From df524b91c1855d2979dd11b0f3e9adfa9bce54aa Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 20 Sep 2023 21:57:52 +0200 Subject: [PATCH 13/46] feat(ui): add style to the "merge accounts" page --- lifemonitor/auth/templates/auth/merge.j2 | 60 +++++++++++++++++++----- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/lifemonitor/auth/templates/auth/merge.j2 b/lifemonitor/auth/templates/auth/merge.j2 index 5406e5e55..a35e3c930 100644 --- a/lifemonitor/auth/templates/auth/merge.j2 +++ b/lifemonitor/auth/templates/auth/merge.j2 @@ -1,15 +1,53 @@ {% 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 %} \ No newline at end of file From 417e2b4f51c4c0612d94a61fd22b7c201832a132 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 15 Jan 2024 11:50:32 +0100 Subject: [PATCH 14/46] style: fix whitespaces --- lifemonitor/auth/templates/auth/merge.j2 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lifemonitor/auth/templates/auth/merge.j2 b/lifemonitor/auth/templates/auth/merge.j2 index a35e3c930..ed2884375 100644 --- a/lifemonitor/auth/templates/auth/merge.j2 +++ b/lifemonitor/auth/templates/auth/merge.j2 @@ -15,24 +15,24 @@

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

{{ form.hidden_tag() }} {{ macros.render_custom_field(form.username,caption="identity username",disabled="true")}} {{ macros.render_custom_field(form.password,caption="identity password") }} -
+
+
From f8287bdd32243066fec10f18801e81475c91f637 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 15 Jan 2024 11:51:16 +0100 Subject: [PATCH 15/46] style: fix missing EOF on merge template --- lifemonitor/auth/templates/auth/merge.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/auth/templates/auth/merge.j2 b/lifemonitor/auth/templates/auth/merge.j2 index ed2884375..bee918876 100644 --- a/lifemonitor/auth/templates/auth/merge.j2 +++ b/lifemonitor/auth/templates/auth/merge.j2 @@ -50,4 +50,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} From bb5102c2fef330856a676ded88f0b2c50d99c3b2 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 16 Jan 2024 14:17:36 +0100 Subject: [PATCH 16/46] chore(view): update "merge accounts" template --- lifemonitor/auth/controllers.py | 5 ++++- lifemonitor/auth/templates/auth/merge.j2 | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 6feaf6f33..73862da6d 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -551,7 +551,10 @@ def merge(): ) return profile() # render the merge page - return render_template("auth/merge.j2", form=form) + return render_template("auth/merge.j2", form=form, identity={ + "username": username, + "provider": provider + }) @blueprint.route("/create_apikey", methods=("POST",)) diff --git a/lifemonitor/auth/templates/auth/merge.j2 b/lifemonitor/auth/templates/auth/merge.j2 index bee918876..43708be94 100644 --- a/lifemonitor/auth/templates/auth/merge.j2 +++ b/lifemonitor/auth/templates/auth/merge.j2 @@ -14,9 +14,14 @@ From 06fd0a18246dacc7c39a5724501b3cfd7f3782ce Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 10:19:50 +0100 Subject: [PATCH 17/46] chore: fix missing dash --- lifemonitor/api/models/repositories/base.py | 2 +- lifemonitor/exceptions.py | 4 ++-- lifemonitor/lang/messages.py | 6 ++++-- lifemonitor/utils.py | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) 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/exceptions.py b/lifemonitor/exceptions.py index dfab2f6f9..03a49572b 100644 --- a/lifemonitor/exceptions.py +++ b/lifemonitor/exceptions.py @@ -158,7 +158,7 @@ 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) @@ -166,7 +166,7 @@ def __init__(self, detail="Not valid RO Crate", 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) 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/utils.py b/lifemonitor/utils.py index fd9d67006..830f6a557 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): From 6d87a94c9d92ec7c5063299bead656a0f3501af8 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 10:31:35 +0100 Subject: [PATCH 18/46] fix: clarify the error message when registry worklow doesn't exist --- lifemonitor/api/controllers.py | 2 ++ lifemonitor/api/models/registries/registry.py | 2 ++ lifemonitor/exceptions.py | 12 ++++++++++++ 3 files changed, 16 insertions(+) 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/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/exceptions.py b/lifemonitor/exceptions.py index 03a49572b..45fd9380f 100644 --- a/lifemonitor/exceptions.py +++ b/lifemonitor/exceptions.py @@ -164,6 +164,18 @@ def __init__(self, detail="Not valid RO-Crate", 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", From b01431cef6aa5b7e35250dc3fe16f1d6178b5c2a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 10:41:41 +0100 Subject: [PATCH 19/46] feat: map title and description on html error pages --- lifemonitor/errors.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lifemonitor/errors.py b/lifemonitor/errors.py index f42442a0b..068e37966 100644 --- a/lifemonitor/errors.py +++ b/lifemonitor/errors.py @@ -59,7 +59,7 @@ def parametric_page(): def handle_400(e: Exception = None, description: str = None): 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) @@ -77,9 +77,9 @@ def handle_404(e: Exception = None): return handle_400(description="Invalid URL") 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, 'details', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Page not found", "resource": resource, @@ -95,9 +95,9 @@ def handle_405(e: Exception = None): return handle_400(decription="Invalid URL") 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, 'details', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Method not allowed for this resource", "resource": escape(resource), @@ -109,9 +109,9 @@ def handle_405(e: Exception = None): def handle_429(e: Exception = None): 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, 'details', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "API rate limit exceeded", } @@ -122,9 +122,9 @@ def handle_429(e: Exception = None): def handle_500(e: Exception = None): 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, 'details', 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", } @@ -135,9 +135,9 @@ def handle_500(e: Exception = None): def handle_502(e: Exception = None): return handle_error( { - "title": "LifeMonitor: Bad Gateway", + "title": getattr(e, 'title', None) or "LifeMonitor: Bad Gateway", "code": "502", - "description": str(e) + "description": getattr(e, 'details', 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", } From 5996ea9e323bb6e6c4fa09e973a74785b9298e20 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 11:20:20 +0100 Subject: [PATCH 20/46] fix(typo): remove final 's' --- lifemonitor/errors.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lifemonitor/errors.py b/lifemonitor/errors.py index 068e37966..eeaad0d56 100644 --- a/lifemonitor/errors.py +++ b/lifemonitor/errors.py @@ -79,7 +79,7 @@ def handle_404(e: Exception = None): { "title": getattr(e, 'title', None) or "LifeMonitor: Page not found", "code": "404", - "description": getattr(e, 'details', None) or str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "Page not found", "resource": resource, @@ -97,7 +97,7 @@ def handle_405(e: Exception = None): { "title": getattr(e, 'title', None) or "LifeMonitor: Method not allowed", "code": "404", - "description": getattr(e, 'details', None) or 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), @@ -111,7 +111,7 @@ def handle_429(e: Exception = None): { "title": getattr(e, 'title', None) or "LifeMonitor: API rate limit exceeded", "code": "429", - "description": getattr(e, 'details', None) or str(e) + "description": getattr(e, 'detail', None) or str(e) if e and logger.isEnabledFor(logging.DEBUG) else "API rate limit exceeded", } @@ -124,7 +124,7 @@ def handle_500(e: Exception = None): { "title": getattr(e, 'title', None) or "LifeMonitor: Internal Server Error", "code": "500", - "description": getattr(e, 'details', None) or 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", } @@ -137,7 +137,7 @@ def handle_502(e: Exception = None): { "title": getattr(e, 'title', None) or "LifeMonitor: Bad Gateway", "code": "502", - "description": getattr(e, 'details', None) or 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", } From c08b90943a6ca445bb1c60ae57b038123d3a071c Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 11:22:33 +0100 Subject: [PATCH 21/46] refactor: rename module function --- lifemonitor/errors.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lifemonitor/errors.py b/lifemonitor/errors.py index eeaad0d56..c52dfb8ea 100644 --- a/lifemonitor/errors.py +++ b/lifemonitor/errors.py @@ -57,7 +57,7 @@ def parametric_page(): @blueprint.route("/400") def handle_400(e: Exception = None, description: str = None): - return handle_error( + return __handle_error__( { "title": getattr(e, 'title', None) or "LifeMonitor: Page not found", "code": "404", @@ -75,7 +75,7 @@ def handle_404(e: Exception = None): 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": getattr(e, 'title', None) or "LifeMonitor: Page not found", "code": "404", @@ -93,7 +93,7 @@ def handle_405(e: Exception = None): logger.debug(f"Method not allowed for resource {resource}") if not validate_url(resource): return handle_400(decription="Invalid URL") - return handle_error( + return __handle_error__( { "title": getattr(e, 'title', None) or "LifeMonitor: Method not allowed", "code": "404", @@ -107,7 +107,7 @@ def handle_405(e: Exception = None): @blueprint.route("/429") def handle_429(e: Exception = None): - return handle_error( + return __handle_error__( { "title": getattr(e, 'title', None) or "LifeMonitor: API rate limit exceeded", "code": "429", @@ -120,7 +120,7 @@ def handle_429(e: Exception = None): @blueprint.route("/500") def handle_500(e: Exception = None): - return handle_error( + return __handle_error__( { "title": getattr(e, 'title', None) or "LifeMonitor: Internal Server Error", "code": "500", @@ -133,7 +133,7 @@ def handle_500(e: Exception = None): @blueprint.route("/502") def handle_502(e: Exception = None): - return handle_error( + return __handle_error__( { "title": getattr(e, 'title', None) or "LifeMonitor: Bad Gateway", "code": "502", @@ -144,7 +144,7 @@ def handle_502(e: Exception = None): ) -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") From 74007e11bbd22e32eb9f1c8755a4c8158f61af4a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 11:27:21 +0100 Subject: [PATCH 22/46] feat(ctrl): format error according to the accepted content type --- lifemonitor/errors.py | 17 ++++++++++++++++- lifemonitor/exceptions.py | 9 +++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lifemonitor/errors.py b/lifemonitor/errors.py index c52dfb8ea..b4dfbee45 100644 --- a/lifemonitor/errors.py +++ b/lifemonitor/errors.py @@ -55,6 +55,18 @@ 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__( @@ -148,7 +160,10 @@ 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 45fd9380f..ea4e6da44 100644 --- a/lifemonitor/exceptions.py +++ b/lifemonitor/exceptions.py @@ -21,12 +21,13 @@ import logging import connexion - from flask import Response, request from werkzeug.exceptions import HTTPException from lifemonitor import serializers +from .errors import handle_error + logger = logging.getLogger(__name__) @@ -228,9 +229,11 @@ def handle_exception(e: Exception): if logger.isEnabledFor(logging.DEBUG): logger.exception(e) if isinstance(e, LifeMonitorException): + 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__, @@ -255,6 +258,8 @@ 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": + return handle_error(LifeMonitorException(title=title, detail=detail, status=status)) if not type: type = 'about:blank' From 687e81884f2ddb8157ce36b89fd4f10917c7964e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 18 Jan 2024 12:04:02 +0100 Subject: [PATCH 23/46] fix: remove circular import --- lifemonitor/errors.py | 4 ++-- lifemonitor/exceptions.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lifemonitor/errors.py b/lifemonitor/errors.py index b4dfbee45..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__) @@ -84,6 +82,7 @@ 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") @@ -103,6 +102,7 @@ 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__( diff --git a/lifemonitor/exceptions.py b/lifemonitor/exceptions.py index ea4e6da44..e186b9cee 100644 --- a/lifemonitor/exceptions.py +++ b/lifemonitor/exceptions.py @@ -26,8 +26,6 @@ from lifemonitor import serializers -from .errors import handle_error - logger = logging.getLogger(__name__) @@ -229,6 +227,7 @@ 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(), @@ -259,6 +258,7 @@ 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' From cb3a639e1005b9e3b7d92aae1ae951fd9449f1ca Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 22 Jan 2024 18:15:09 +0100 Subject: [PATCH 24/46] fix(util): absence of git remotes should not block --- lifemonitor/api/models/repositories/local.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/models/repositories/local.py b/lifemonitor/api/models/repositories/local.py index 6a8e9d2a5..1f208b646 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) From 5b85dc256cbb6a5a1328d31f896f5e94272dd0b6 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 23 Jan 2024 09:01:31 +0100 Subject: [PATCH 25/46] feat(model): check git workflow for the presence of at least one Git branch --- lifemonitor/api/models/issues/general/lm.py | 1 + .../api/models/issues/general/repo_layout.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/lifemonitor/api/models/issues/general/lm.py b/lifemonitor/api/models/issues/general/lm.py index 3e219d286..10912bfe3 100644 --- a/lifemonitor/api/models/issues/general/lm.py +++ b/lifemonitor/api/models/issues/general/lm.py @@ -36,6 +36,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..951861f5d 100644 --- a/lifemonitor/api/models/issues/general/repo_layout.py +++ b/lifemonitor/api/models/issues/general/repo_layout.py @@ -29,10 +29,25 @@ 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 repo.is_git_repo(repo.local_path): + return False + return repo.main_branch is None + + 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 From 4b9632da8e91e307259b15f289d61021234b7f40 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 23 Jan 2024 10:44:21 +0100 Subject: [PATCH 26/46] fix: check property existence --- lifemonitor/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lifemonitor/utils.py b/lifemonitor/utils.py index 830f6a557..d6b9187f2 100644 --- a/lifemonitor/utils.py +++ b/lifemonitor/utils.py @@ -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 From a2517561eeef794cbaeca637457a5bb510425d77 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 23 Jan 2024 13:13:32 +0100 Subject: [PATCH 27/46] feat(model): more properties to inspect git workflow repos --- lifemonitor/api/models/repositories/local.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lifemonitor/api/models/repositories/local.py b/lifemonitor/api/models/repositories/local.py index 1f208b646..b594868ab 100644 --- a/lifemonitor/api/models/repositories/local.py +++ b/lifemonitor/api/models/repositories/local.py @@ -310,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 \ From 0c48c44be856e9d45d0bfd84932fe41b484b7315 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 23 Jan 2024 13:14:09 +0100 Subject: [PATCH 28/46] fix(model): check remote branches --- lifemonitor/api/models/issues/general/repo_layout.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lifemonitor/api/models/issues/general/repo_layout.py b/lifemonitor/api/models/issues/general/repo_layout.py index 951861f5d..ab4bfab30 100644 --- a/lifemonitor/api/models/issues/general/repo_layout.py +++ b/lifemonitor/api/models/issues/general/repo_layout.py @@ -24,6 +24,8 @@ 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__) @@ -40,7 +42,9 @@ def check(self, repo: WorkflowRepository) -> bool: """ if not repo.is_git_repo(repo.local_path): return False - return repo.main_branch is None + 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): From bf050b33edd3ec630994abf28f9c8bc7ab762543 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 23 Jan 2024 15:36:43 +0100 Subject: [PATCH 29/46] fix(model): replace check name with the corresponding class type --- lifemonitor/api/models/issues/general/lm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lifemonitor/api/models/issues/general/lm.py b/lifemonitor/api/models/issues/general/lm.py index 10912bfe3..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,7 +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"] + depends_on = [GitRepositoryWithoutMainBranch] def check(self, repo: WorkflowRepository) -> bool: if repo.config is None: From c23b0d6c032f74a76e212def8746d725b8e1c325 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 25 Jan 2024 12:26:51 +0100 Subject: [PATCH 30/46] fix: reference static method using class reference --- lifemonitor/api/models/issues/general/repo_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lifemonitor/api/models/issues/general/repo_layout.py b/lifemonitor/api/models/issues/general/repo_layout.py index ab4bfab30..556ffbb64 100644 --- a/lifemonitor/api/models/issues/general/repo_layout.py +++ b/lifemonitor/api/models/issues/general/repo_layout.py @@ -40,7 +40,7 @@ def check(self, repo: WorkflowRepository) -> bool: """ If the repository is a Git repository, check if it has a main branch. """ - if not repo.is_git_repo(repo.local_path): + 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) From ee7796fead638a0d54501b3cddaef7b424d69d30 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Sun, 25 Feb 2024 21:56:04 +0100 Subject: [PATCH 31/46] fix: change log level of redis_lock module --- lifemonitor/redis.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lifemonitor/redis.py b/lifemonitor/redis.py index 29e4453b8..d584bdf11 100644 --- a/lifemonitor/redis.py +++ b/lifemonitor/redis.py @@ -37,4 +37,19 @@ def init(app: Flask) -> Redis: port=int(app.config.get("REDIS_PORT_NUMBER", 6379)), password=app.config.get("REDIS_PASSWORD", "foobar"), db=0) + + # fix logger level + import logging + + import redis_lock + + redis_lock_logger_level = logging.ERROR + 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__ From c91be611b27a493dd4d564c7cf77e9038e331d85 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Sun, 25 Feb 2024 23:06:19 +0100 Subject: [PATCH 32/46] fix: reference the proper local_repo var on cleanup --- lifemonitor/api/models/repositories/github.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From d2ead9933a0202a89b8c0066cfc761e6345e4aa1 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Mon, 26 Feb 2024 15:48:41 +0100 Subject: [PATCH 33/46] feat: enable more verbose logging level according to the app config --- lifemonitor/redis.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lifemonitor/redis.py b/lifemonitor/redis.py index d584bdf11..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 @@ -38,12 +41,10 @@ def init(app: Flask) -> Redis: password=app.config.get("REDIS_PASSWORD", "foobar"), db=0) - # fix logger level - import logging - - import redis_lock - - redis_lock_logger_level = logging.ERROR + # 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) From 3ef763a499e16b5e03243b08f46c5ac18818cea0 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Sun, 25 Feb 2024 22:07:02 +0100 Subject: [PATCH 34/46] fix: set default gunicorn timeout to 60s --- docker/lm_entrypoint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index a2c7bce23..a82b60cad 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -28,6 +28,7 @@ else --threads "${GUNICORN_THREADS}" \ --config "${GUNICORN_CONF}" \ --certfile="${CERT}" --keyfile="${KEY}" \ + --timeout 60 \ -b "0.0.0.0:8000" \ "app" fi From 9a715c730adc9674e00c9995712ae775ae18259a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 27 Feb 2024 15:00:18 +0100 Subject: [PATCH 35/46] feat: enable GUNICORN configuration --- docker/lm_entrypoint.sh | 20 +++++++++++++------- k8s/templates/settings.secret.yaml | 10 ++++++++-- settings.conf | 6 ++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index a82b60cad..2596313b5 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -24,11 +24,17 @@ else export PROMETHEUS_MULTIPROC_DIR=$(mktemp -d ${metrics_base_path}/backend.XXXXXXXX) fi export GUNICORN_SERVER="true" - gunicorn --workers "${GUNICORN_WORKERS}" \ - --threads "${GUNICORN_THREADS}" \ - --config "${GUNICORN_CONF}" \ - --certfile="${CERT}" --keyfile="${KEY}" \ - --timeout 60 \ - -b "0.0.0.0:8000" \ - "app" + 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}" \ + --keepalive "${GUNICORN_KEEPALIVE}" \ + --config "${GUNICORN_CONF}" \ + --certfile="${CERT}" --keyfile="${KEY}" \ + -b "0.0.0.0:8000" \ + "app" fi 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/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 From 558a60f16c751078924e2fe3bfea94ac473ffb65 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 27 Feb 2024 17:32:26 +0100 Subject: [PATCH 36/46] chore: add GUNICORN values on k8s deploement --- k8s/values.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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: From e665bbca8dddf253d3df8353834bd17b638b380e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 27 Feb 2024 18:39:27 +0100 Subject: [PATCH 37/46] fix: properly configure gunicorn settings on Docker entrypoint --- docker/lm_entrypoint.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index 2596313b5..90fb680f4 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -23,7 +23,20 @@ else mkdir -p ${metrics_base_path} export PROMETHEUS_MULTIPROC_DIR=$(mktemp -d ${metrics_base_path}/backend.XXXXXXXX) fi + + # gunicorn settings export GUNICORN_SERVER="true" + export GUNICORN_WORKERS="${GUNICORN_WORKERS:-2}" + export GUNICORN_THREADS="${GUNICORN_THREADS:-1}" + 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}" From b0a49757846a97bd81684941ac223628f435a986 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Tue, 27 Feb 2024 18:41:39 +0100 Subject: [PATCH 38/46] build(k8s): configure gunicorn settings on pod env --- k8s/templates/_helpers.tpl | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 From c1afa094bbf912d4a49d0297c6aa7d5b8b0b6593 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 28 Feb 2024 10:11:27 +0100 Subject: [PATCH 39/46] fix: update syntax for gunicorn parameters --- docker/lm_entrypoint.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index 90fb680f4..1b46126df 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -39,12 +39,12 @@ else 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}" \ + --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}" \ + --graceful-timeout "${GUNICORN_GRACEFUL_TIMEOUT}" \ --keepalive "${GUNICORN_KEEPALIVE}" \ --config "${GUNICORN_CONF}" \ --certfile="${CERT}" --keyfile="${KEY}" \ From 7f00ce90889f2a2b1de997c1aebe27af66372158 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 28 Feb 2024 10:19:20 +0100 Subject: [PATCH 40/46] fix: missing line continuation character --- docker/lm_entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index 1b46126df..4e38f8ca7 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -39,7 +39,7 @@ else printf "Starting app in PROD mode (Gunicorn)" gunicorn --workers "${GUNICORN_WORKERS}" \ --threads "${GUNICORN_THREADS}" \ - --max-requests "${GUNICORN_MAX_REQUESTS}" + --max-requests "${GUNICORN_MAX_REQUESTS}" \ --max-requests-jitter "${GUNICORN_MAX_REQUESTS_JITTER}" \ --worker-connections "${GUNICORN_WORKER_CONNECTIONS}" \ --worker-class "${GUNICORN_WORKER_CLASS}" \ From 3a04e6b0f0b1a8eab23de99397367a8ece402c3e Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 28 Feb 2024 10:28:40 +0100 Subject: [PATCH 41/46] fix: missing worker-class gunicorn parameter --- docker/lm_entrypoint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index 4e38f8ca7..a12fd0bfe 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -28,6 +28,7 @@ else export GUNICORN_SERVER="true" 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}" From 307f0dda0340863202444c7feff7fc29cafefed3 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 28 Feb 2024 10:36:34 +0100 Subject: [PATCH 42/46] fix: keep-alive parameter --- docker/lm_entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/lm_entrypoint.sh b/docker/lm_entrypoint.sh index a12fd0bfe..c83858c16 100644 --- a/docker/lm_entrypoint.sh +++ b/docker/lm_entrypoint.sh @@ -46,7 +46,7 @@ else --worker-class "${GUNICORN_WORKER_CLASS}" \ --timeout "${GUNICORN_TIMEOUT}" \ --graceful-timeout "${GUNICORN_GRACEFUL_TIMEOUT}" \ - --keepalive "${GUNICORN_KEEPALIVE}" \ + --keep-alive "${GUNICORN_KEEPALIVE}" \ --config "${GUNICORN_CONF}" \ --certfile="${CERT}" --keyfile="${KEY}" \ -b "0.0.0.0:8000" \ From 73aaf157964790b13ee25b0c84d848cd387b588d Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Wed, 28 Feb 2024 19:21:56 +0100 Subject: [PATCH 43/46] fix: move the timeout configuration to the correct position --- k8s/templates/nginx-configmap.yaml | 254 +++++++++++++++++------------ 1 file changed, 150 insertions(+), 104 deletions(-) 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; + } From 5346e46ea511c221173edf01a71969e86bd213e5 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 29 Feb 2024 11:42:48 +0100 Subject: [PATCH 44/46] build(ci): update action/checkout to v4 --- .github/workflows/docs.yaml | 2 +- .github/workflows/main.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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..45a428938 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -37,7 +37,7 @@ 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 with: @@ -56,7 +56,7 @@ 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 with: @@ -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 From 34b718fa4902dc3b762caf9d914100391b257d8a Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 29 Feb 2024 11:55:28 +0100 Subject: [PATCH 45/46] build(ci): update 'actions/python' to v5 --- .github/workflows/main.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 45a428938..a6ff9342e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -39,7 +39,7 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - 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 @@ -58,7 +58,7 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - 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 From aa4e6759cfe374e1d335dd73721d8fd3cd30d731 Mon Sep 17 00:00:00 2001 From: Marco Enrico Piras Date: Thu, 29 Feb 2024 12:53:12 +0100 Subject: [PATCH 46/46] build: bump version number to 0.13.0 --- k8s/Chart.yaml | 4 ++-- lifemonitor/static/src/package.json | 2 +- specs/api.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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/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/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