diff --git a/cli/client/issues.py b/cli/client/issues.py index 4097a51ac..71a24a141 100644 --- a/cli/client/issues.py +++ b/cli/client/issues.py @@ -147,7 +147,8 @@ def check(config, repository, output_path): console.print(f"[{message.type.value}]{message.type.name}:[/{message.type.value}] {message.text}") console.print("\n\n") except Exception as e: - logger.exception(e) + if logger.isEnabledFor(logging.DEBUG): + logger.exception(e) error_console.print(str(e)) diff --git a/cli/client/utils.py b/cli/client/utils.py index affd0152e..a47d8a713 100644 --- a/cli/client/utils.py +++ b/cli/client/utils.py @@ -25,7 +25,7 @@ from urllib.parse import urlparse from lifemonitor.api.models.repositories.github import GithubWorkflowRepository -from lifemonitor.api.models.repositories.local import LocalWorkflowRepository +from lifemonitor.api.models.repositories.local import LocalWorkflowRepository, LocalGitRepository from rich.prompt import Prompt # Set module logger @@ -42,15 +42,20 @@ def is_url(value): def get_repository(repository: str, local_path: str): assert repository, repository - if is_url(repository): - remote_repo_url = repository - if remote_repo_url.endswith('.git'): - return GithubWorkflowRepository.from_url(remote_repo_url, auto_cleanup=False, local_path=local_path) - else: - local_copy_path = os.path.join(local_path, os.path.basename(repository)) - shutil.copytree(repository, local_copy_path) - return LocalWorkflowRepository(local_copy_path) - raise ValueError("Repository type not supported") + try: + if is_url(repository): + remote_repo_url = repository + if remote_repo_url.endswith('.git'): + return GithubWorkflowRepository.from_url(remote_repo_url, auto_cleanup=False, local_path=local_path) + else: + local_copy_path = os.path.join(local_path, os.path.basename(repository)) + shutil.copytree(repository, local_copy_path) + if LocalGitRepository.is_git_repo(local_copy_path): + return LocalGitRepository(local_copy_path) + return LocalWorkflowRepository(local_copy_path) + raise ValueError("Repository type not supported") + except Exception as e: + raise ValueError("Error while loading the repository: %s" % e) def init_output_path(output_path): diff --git a/lifemonitor/api/models/registries/registry.py b/lifemonitor/api/models/registries/registry.py index 85381d3de..09992edd3 100644 --- a/lifemonitor/api/models/registries/registry.py +++ b/lifemonitor/api/models/registries/registry.py @@ -24,10 +24,13 @@ from abc import ABC, abstractmethod from typing import List, Tuple, Union -import lifemonitor.api.models as models -import lifemonitor.exceptions as lm_exceptions import requests from authlib.integrations.base_client import RemoteApp +from sqlalchemy.orm.collections import attribute_mapped_collection +from sqlalchemy.orm.exc import NoResultFound + +import lifemonitor.api.models as models +import lifemonitor.exceptions as lm_exceptions from lifemonitor import utils as lm_utils from lifemonitor.api.models import db from lifemonitor.api.models.repositories.base import WorkflowRepository @@ -35,9 +38,7 @@ from lifemonitor.auth.models import Resource from lifemonitor.auth.oauth2.client.models import OAuthIdentity from lifemonitor.auth.oauth2.client.services import oauth2_registry -from lifemonitor.utils import ClassManager, download_url -from sqlalchemy.orm.collections import attribute_mapped_collection -from sqlalchemy.orm.exc import NoResultFound +from lifemonitor.utils import ClassManager, download_url, is_service_alive # set module level logger logger = logging.getLogger(__name__) @@ -440,6 +441,8 @@ def delete_workflow(self, submitter: auth_models.User, external_id: str) -> bool @property def client(self) -> WorkflowRegistryClient: + if not is_service_alive(self.uri): + raise lm_exceptions.UnavailableServiceException(f"Service {self.uri} is not available", service=self) if self._client is None: rtype = self.__class__.__name__.replace("WorkflowRegistry", "").lower() return WorkflowRegistryClient.get_client_class(rtype)(self) diff --git a/lifemonitor/api/models/repositories/base.py b/lifemonitor/api/models/repositories/base.py index d65e8ba97..80114ba34 100644 --- a/lifemonitor/api/models/repositories/base.py +++ b/lifemonitor/api/models/repositories/base.py @@ -297,11 +297,17 @@ def config(self) -> WorkflowRepositoryConfig: pass return self._config - def generate_config(self, ignore_existing=False) -> WorkflowFile: + def generate_config(self, ignore_existing=False, + workflow_title: Optional[str] = None, + public: bool = False, main_branch: Optional[str] = None) -> WorkflowFile: current_config = self.config if current_config and not ignore_existing: raise IllegalStateException("Config exists") - self._config = WorkflowRepositoryConfig.new(self.local_path, workflow_title=self.metadata.main_entity_name if self.metadata else None) + self._config = WorkflowRepositoryConfig.new(self.local_path, + workflow_title=workflow_title if workflow_title is not None + else self.metadata.main_entity_name if self.metadata else None, + main_branch=main_branch if main_branch else getattr(self, "main_branch", "main"), + public=public) return self._config def write_zip(self, target_path: str): diff --git a/lifemonitor/api/models/repositories/local.py b/lifemonitor/api/models/repositories/local.py index f9345cab9..890a2c93c 100644 --- a/lifemonitor/api/models/repositories/local.py +++ b/lifemonitor/api/models/repositories/local.py @@ -174,3 +174,20 @@ def __init__(self, base64_rocrate: str) -> None: except Exception as e: logger.debug(e) raise DecodeROCrateException(detail=str(e)) + + +class LocalGitRepository(LocalWorkflowRepository): + + def __init__(self, local_path: str | None = None, exclude: List[str] | None = None) -> None: + super().__init__(local_path, exclude) + assert self.is_git_repo(self.local_path), f"Local path {local_path} is not a git repository" + + @property + def main_branch(self) -> str: + from git import Repo + repo = Repo(self.local_path) + return repo.active_branch.name + + @staticmethod + def is_git_repo(local_path: str) -> bool: + return os.path.isdir(os.path.join(local_path, '.git')) diff --git a/lifemonitor/api/models/rocrate/__init__.py b/lifemonitor/api/models/rocrate/__init__.py index cb4863895..0b1aff921 100644 --- a/lifemonitor/api/models/rocrate/__init__.py +++ b/lifemonitor/api/models/rocrate/__init__.py @@ -28,9 +28,13 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple -import lifemonitor.exceptions as lm_exceptions from flask import current_app from github.GithubException import GithubException, RateLimitExceededException +from rocrate import rocrate +from sqlalchemy import inspect +from sqlalchemy.ext.hybrid import hybrid_property + +import lifemonitor.exceptions as lm_exceptions from lifemonitor.api.models import db, repositories from lifemonitor.api.models.repositories.base import ( WorkflowRepository, WorkflowRepositoryMetadata) @@ -43,10 +47,6 @@ from lifemonitor.models import JSON from lifemonitor.storage import RemoteStorage from lifemonitor.utils import download_url, get_current_ref -from sqlalchemy import inspect -from sqlalchemy.ext.hybrid import hybrid_property - -from rocrate import rocrate # set module level logger logger = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def storage_path(self) -> str: @property def revision(self) -> Optional[GithubRepositoryRevision]: - if self._is_github_crate_(self.uri) and isinstance(self.repository, GithubWorkflowRepository): + if self._get_normalized_github_url_(self.uri) and isinstance(self.repository, GithubWorkflowRepository): return self.repository.get_revision(self.version) return None @@ -172,7 +172,7 @@ def repository(self) -> repositories.WorkflowRepository: logger.warning(f"Getting path {self.storage_path} from remote storage.... DONE!!!") # instantiate a local ROCrate repository - if self._is_github_crate_(self.uri): + if self._get_normalized_github_url_(self.uri): authorizations = self.authorizations + [None] token = None for authorization in authorizations: @@ -192,8 +192,13 @@ def repository(self) -> repositories.WorkflowRepository: self._repository = repositories.ZippedWorkflowRepository(self.local_path) # set metadata - self._metadata = self._repository.metadata.to_json() - self._metadata_loaded = True + try: + self._metadata = self._repository.metadata.to_json() + self._metadata_loaded = True + except Exception as e: + if logger.isEnabledFor(logging.DEBUG): + logger.exception(e) + raise lm_exceptions.NotValidROCrateException("Unable to load ROCrate metadata") return self._repository @property @@ -215,13 +220,13 @@ def __get_attribute_from_crate_reader__(self, if logger.isEnabledFor(logging.DEBUG): logger.exception(e) if not ignore_errors: - raise e + raise except Exception as e: logger.error(str(e)) if logger.isEnabledFor(logging.DEBUG): logger.exception(e) if not ignore_errors: - raise e + raise return None def get_authors(self, suite_id: str = None) -> List[Dict] | None: @@ -284,14 +289,9 @@ def download(self, target_path: str) -> str: return (tmpdir_path / 'rocrate.zip').as_posix() @staticmethod - def _is_github_crate_(uri: str) -> str: - # FIXME: replace with a better detection mechanism - if uri.startswith('https://github.com'): - # normalize uri as clone URL - if not uri.endswith('.git'): - uri += '.git' - return uri - return None + def _get_normalized_github_url_(uri: str) -> Optional[str]: + from lifemonitor.integrations.github.utils import normalized_github_url + return normalized_github_url(uri) @staticmethod def _find_git_ref_(repo: GithubWorkflowRepository, version: str) -> str: @@ -327,7 +327,7 @@ def download_from_source(self, target_path: str = None, uri: str = None, version # try either with authorization header and without authorization for authorization in self._get_authorizations(extra_auth=extra_auth): try: - git_url = self._is_github_crate_(uri) + git_url = self._get_normalized_github_url_(uri) if git_url: token = None if authorization and isinstance(authorization, ExternalServiceAuthorizationHeader): diff --git a/lifemonitor/api/models/rocrate/generators.py b/lifemonitor/api/models/rocrate/generators.py index f1c0cdabc..a257d8721 100644 --- a/lifemonitor/api/models/rocrate/generators.py +++ b/lifemonitor/api/models/rocrate/generators.py @@ -37,8 +37,8 @@ def generate_crate(workflow_type: str, workflow_version: str, local_repo_path: str, repo_url: Optional[str], - license: Optional[str] = "MIT", - ci_workflow: Optional[str] = "main.yml", + license: Optional[str] = None, + ci_workflow: Optional[str] = None, lang_version: Optional[str] = None, **kwargs): make_crate = get_crate_generator(workflow_type) diff --git a/lifemonitor/api/services.py b/lifemonitor/api/services.py index 09ba086c7..2f7f71910 100644 --- a/lifemonitor/api/services.py +++ b/lifemonitor/api/services.py @@ -34,7 +34,7 @@ from lifemonitor.auth.oauth2.client.models import OAuthIdentity from lifemonitor.auth.oauth2.server import server from lifemonitor.tasks.models import Job -from lifemonitor.utils import OpenApiSpecs, ROCrateLinkContext, to_snake_case +from lifemonitor.utils import OpenApiSpecs, ROCrateLinkContext, is_service_alive, to_snake_case from lifemonitor.ws import io logger = logging.getLogger() @@ -58,6 +58,9 @@ def __init__(self): def _find_and_check_shared_workflow_version(user: User, uuid, version=None) -> models.WorkflowVersion: for svc in models.WorkflowRegistry.all(): try: + if not is_service_alive(svc.uri): + logger.warning(f"Service {svc.uri} is not alive") + continue if svc.get_user(user.id): for w in svc.get_user_workflows(user): if str(w.uuid) == str(uuid): @@ -559,6 +562,9 @@ def get_user_workflows(user: User, workflows = [w for w in models.Workflow.get_user_workflows(user, include_subscriptions=include_subscriptions)] for svc in models.WorkflowRegistry.all(): + if not is_service_alive(svc.uri): + logger.warning("Service %r is not alive, skipping", svc.uri) + continue if svc.get_user(user.id): try: workflows.extend([w for w in svc.get_user_workflows(user) @@ -570,6 +576,9 @@ def get_user_workflows(user: User, @staticmethod def get_user_registry_workflows(user: User, registry: models.WorkflowRegistry) -> List[models.Workflow]: workflows = [] + if not is_service_alive(registry.uri): + logger.warning("Service %r is not alive, skipping", registry.uri) + raise lm_exceptions.UnavailableServiceException(registry.uri, service=registry) if registry.get_user(user.id): try: workflows.extend([w for w in registry.get_user_workflows(user) diff --git a/lifemonitor/app.py b/lifemonitor/app.py index d37a60628..edb8ceb13 100644 --- a/lifemonitor/app.py +++ b/lifemonitor/app.py @@ -45,7 +45,8 @@ logger = logging.getLogger(__name__) -def create_app(env=None, settings=None, init_app=True, worker=False, load_jobs=True, **kwargs): +def create_app(env=None, settings=None, init_app=True, init_integrations=True, + worker=False, load_jobs=True, **kwargs): """ App factory method :param env: @@ -81,7 +82,7 @@ def create_app(env=None, settings=None, init_app=True, worker=False, load_jobs=T # initialize the application if init_app: with app.app_context() as ctx: - initialize_app(app, ctx, load_jobs=load_jobs) + initialize_app(app, ctx, load_jobs=load_jobs, load_integrations=init_integrations) @app.route("/") def index(): @@ -127,7 +128,7 @@ def log_response(response): return app -def initialize_app(app: Flask, app_context, prom_registry=None, load_jobs: bool = True): +def initialize_app(app: Flask, app_context, prom_registry=None, load_jobs: bool = True, load_integrations: bool = True): # init tmp folder os.makedirs(app.config.get('BASE_TEMP_FOLDER'), exist_ok=True) # enable CORS @@ -151,7 +152,8 @@ def initialize_app(app: Flask, app_context, prom_registry=None, load_jobs: bool # init mail system init_mail(app) # initialize integrations - init_integrations(app) + if load_integrations: + init_integrations(app) # initialize metrics engine init_metrics(app, prom_registry) # register commands diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 3ac44c569..dfa49a652 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -22,14 +22,17 @@ import connexion import flask -from flask import current_app, flash, redirect, render_template, request, session, url_for +from flask import (current_app, flash, redirect, render_template, request, + session, url_for) from flask_login import login_required, login_user, logout_user + from lifemonitor.cache import Timeout, cached, clear_cache from lifemonitor.utils import (NextRouteRegistry, next_route_aware, split_by_crlf) from .. import exceptions -from ..utils import OpenApiSpecs, boolean_value +from ..utils import (OpenApiSpecs, boolean_value, get_external_server_url, + is_service_alive) from . import serializers from .forms import (EmailForm, LoginForm, NotificationsForm, Oauth2ClientForm, RegisterForm, SetPasswordForm) @@ -190,6 +193,7 @@ def profile(form=None, passwordForm=None, currentView=None, registrySettingsForm=registrySettingsForm or RegistrySettingsForm.from_model(current_user), providers=get_providers(), currentView=currentView, oauth2_generic_client_scopes=OpenApiSpecs.get_instance().authorization_code_scopes, + api_base_url=get_external_server_url(), back_param=back_param) @@ -210,7 +214,8 @@ def register(): clear_cache() return redirect(url_for("auth.index")) return render_template("auth/register.j2", form=form, - action=url_for('auth.register'), providers=get_providers()) + action=url_for('auth.register'), + providers=get_providers(), is_service_available=is_service_alive) @blueprint.route("/identity_not_found", methods=("GET", "POST")) @@ -259,6 +264,7 @@ def login(): form = LoginForm() flask.session["confirm_user_details"] = True flask.session["sign_in"] = True + flask.session.pop('_flashes', None) if form.validate_on_submit(): user = form.get_user() if user: @@ -266,7 +272,8 @@ def login(): session.pop('_flashes', None) flash("You have logged in", category="success") return redirect(NextRouteRegistry.pop(url_for("auth.profile"))) - return render_template("auth/login.j2", form=form, providers=get_providers()) + return render_template("auth/login.j2", form=form, + providers=get_providers(), is_service_available=is_service_alive) @blueprint.route("/logout") @@ -276,7 +283,9 @@ def logout(): session.pop('_flashes', None) flash("You have logged out", category="success") NextRouteRegistry.clear() - return redirect('/') + next_route = request.args.get('next', '/') + logger.debug("Next route after logout: %r", next_route) + return redirect(next_route) @blueprint.route("/delete_account", methods=("POST",)) diff --git a/lifemonitor/auth/oauth2/client/controllers.py b/lifemonitor/auth/oauth2/client/controllers.py index 1e6682808..9ba70b8fb 100644 --- a/lifemonitor/auth/oauth2/client/controllers.py +++ b/lifemonitor/auth/oauth2/client/controllers.py @@ -24,17 +24,21 @@ from authlib.integrations.base_client.errors import OAuthError from authlib.integrations.flask_client import FlaskRemoteApp -from flask import (Blueprint, abort, current_app, flash, redirect, request, session, url_for) +from flask import (Blueprint, abort, current_app, flash, redirect, request, + session, url_for) from flask_login import current_user, login_user + from lifemonitor import exceptions, utils from lifemonitor.auth.models import User from lifemonitor.auth.oauth2.client.models import ( OAuth2IdentityProvider, OAuthIdentityNotFoundException) from lifemonitor.db import db -from lifemonitor.utils import NextRouteRegistry, next_route_aware +from lifemonitor.utils import (NextRouteRegistry, is_service_alive, + next_route_aware) from .models import OAuthIdentity, OAuthUserProfile -from .services import config_oauth2_registry, oauth2_registry, save_current_user_identity +from .services import (config_oauth2_registry, oauth2_registry, + save_current_user_identity) from .utils import RequestHelper # Config a module level logger @@ -94,6 +98,9 @@ def login(name, scope: str = None): remote = oauth2_registry.create_client(name) if remote is None: abort(404) + logger.debug("config: %r", remote.OAUTH_APP_CONFIG) + if not is_service_alive(remote.OAUTH_APP_CONFIG['api_base_url']): + abort(503) action = request.args.get('action', False) if action and action == 'sign-in': session['sign_in'] = True diff --git a/lifemonitor/auth/oauth2/client/models.py b/lifemonitor/auth/oauth2/client/models.py index 559693a75..90118bbfc 100644 --- a/lifemonitor/auth/oauth2/client/models.py +++ b/lifemonitor/auth/oauth2/client/models.py @@ -40,7 +40,7 @@ LifeMonitorException, NotAuthorizedException) from lifemonitor.models import JSON, ModelMixin -from lifemonitor.utils import to_snake_case +from lifemonitor.utils import assert_service_is_alive, to_snake_case from sqlalchemy import DateTime, inspect from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.attributes import flag_modified @@ -206,6 +206,8 @@ def get_token(self, scope: Optional[str] = None): return OAuth2Token(token, provider=self.provider) def fetch_token(self, scope: Optional[str] = None): + # ensure that the service is alive + assert_service_is_alive(self.provider.api_base_url) # enable dynamic refresh only if the identity # has been already stored in the database if inspect(self).persistent: @@ -226,6 +228,8 @@ def refresh_token(self, token: OAuth2Token = None): logger.debug("Refresh token requested...") token = token or self.token if token and token.to_be_refreshed(): + # ensure that the service is alive + assert_service_is_alive(self.provider.api_base_url) with self.cache.lock(str(self), timeout=Timeout.NONE): # fetch current token from database self.refresh(attribute_names=['_tokens']) @@ -424,6 +428,7 @@ def token_type(self): return "Bearer" def get_user_info(self, provider_user_id, token, normalized=True): + assert_service_is_alive(self.api_base_url) access_token = token['access_token'] if isinstance(token, dict) else token response = requests.get(urljoin(self.api_base_url, self.userinfo_endpoint), headers={'Authorization': f'Bearer {access_token}'}) @@ -505,6 +510,7 @@ def find_identity_by_provider_user_id(self, provider_user_id): def refresh_token(self, token: OAuth2Token) -> OAuth2Token: logger.debug(f"Trying to refresh the token: {token}...") + assert_service_is_alive(self.api_base_url) # reference to the token associated with the identity instance oauth2session = OAuth2Session( self.client_id, self.client_secret, token=token) diff --git a/lifemonitor/auth/templates/auth/apikeys_tab.j2 b/lifemonitor/auth/templates/auth/apikeys_tab.j2 index e0ab14daf..6366bb225 100644 --- a/lifemonitor/auth/templates/auth/apikeys_tab.j2 +++ b/lifemonitor/auth/templates/auth/apikeys_tab.j2 @@ -7,7 +7,7 @@
- OR -
{% for p in providers %} {% if p.client_name != 'lsaai' %} - {{ macros.render_provider_signup_button(p) }} + {{ macros.render_provider_signup_button(p,not is_service_available(p.oauth_config['api_base_url'])) }} {% endif %} {% endfor %}