From bbf32e61353121332870e3670d84d647fa7df812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Alvergnat?= Date: Thu, 17 Dec 2020 22:08:05 +0100 Subject: [PATCH] feat(core): Add `core.required_version` parameter to enforce project ddb version requirement (#75) Close #75 --- ddb/__main__.py | 3 + ddb/action/runner.py | 33 +++++++++-- ddb/feature/core/__init__.py | 7 ++- ddb/feature/core/actions.py | 57 ++++++++++++++++++- ddb/feature/core/schema.py | 1 + docs/features/core.md | 7 +++ .../test_core.data/required-version/ddb.yml | 2 + .../required-version/test.jinja | 1 + tests/it/test_core.py | 57 ++++++++++++++++++- 9 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 tests/it/test_core.data/required-version/ddb.yml create mode 100644 tests/it/test_core.data/required-version/test.jinja diff --git a/ddb/__main__.py b/ddb/__main__.py index 72227fe5..d2c1a6eb 100644 --- a/ddb/__main__.py +++ b/ddb/__main__.py @@ -15,6 +15,7 @@ from ddb.action import actions from ddb.action.action import EventBinding, Action, WatchSupport +from ddb.action.runner import ExpectedError from ddb.action.runnerfactory import action_event_binding_runner_factory from ddb.binary import binaries from ddb.cache import caches, global_cache_name, requests_cache_name, \ @@ -393,6 +394,8 @@ def on_config_reloaded(): events.main.terminate(command=command) return context.exceptions + except ExpectedError as exception: + return [exception] finally: if not reset_disabled: reset() diff --git a/ddb/action/runner.py b/ddb/action/runner.py index 63d9dc02..bb683076 100644 --- a/ddb/action/runner.py +++ b/ddb/action/runner.py @@ -11,6 +11,28 @@ A = TypeVar('A', bound=Action) # pylint:disable=invalid-name +class FailFastError(Exception): + """ + A base exception that should always fail fast, even when flag is not enabled. + """ + + +class ExpectedError(Exception): + """ + A base exception that can display it's own message in logs. + """ + + def log_error(self): + """ + Log the error + :return: + """ + log_error = context.log.exception if \ + context.log.isEnabledFor(logging.DEBUG) or config.args.exceptions \ + else context.log.error + log_error(str(self)) + + # pylint:disable=too-few-public-methods @@ -60,7 +82,7 @@ def run(self, *args, **kwargs): # pylint:disable=missing-function-docstring return True except Exception as exception: # pylint:disable=broad-except self._handle_exception(exception) - if self.fail_fast: # TODO: Ajouter une option à la ligne de commande + if self.fail_fast or isinstance(exception, FailFastError): raise finally: @@ -82,9 +104,12 @@ def _handle_exception(exception: Exception): log_error = context.log.exception if \ context.log.isEnabledFor(logging.DEBUG) or config.args.exceptions \ else context.log.error - log_error("An unexpected error has occured %s: %s", - context.stack, - str(exception).strip()) + if isinstance(exception, ExpectedError): + exception.log_error() + else: + log_error("An unexpected error has occured %s: %s", + context.stack, + str(exception).strip()) if isinstance(exception, CalledProcessError): if exception.stderr: for err_line in exception.stderr.decode("utf-8").splitlines(): diff --git a/ddb/feature/core/__init__.py b/ddb/feature/core/__init__.py index 649ed274..0efa39fa 100644 --- a/ddb/feature/core/__init__.py +++ b/ddb/feature/core/__init__.py @@ -6,7 +6,7 @@ from dotty_dict import Dotty from .actions import FeaturesAction, ConfigAction, ReloadConfigAction, EjectAction, SelfUpdateAction, \ - MainCheckForUpdateAction, VersionAction + CheckForUpdateAction, VersionAction, CheckRequiredVersion from .schema import CoreFeatureSchema from ..feature import Feature, FeatureConfigurationAutoConfigureError from ..schema import FeatureSchema @@ -44,8 +44,9 @@ def actions(self) -> Iterable[Action]: ReloadConfigAction(), EjectAction(), SelfUpdateAction(), - MainCheckForUpdateAction(), - VersionAction() + CheckForUpdateAction(), + VersionAction(), + CheckRequiredVersion() ) @property diff --git a/ddb/feature/core/actions.py b/ddb/feature/core/actions.py index 7a6fd7a5..00bad3df 100644 --- a/ddb/feature/core/actions.py +++ b/ddb/feature/core/actions.py @@ -21,6 +21,7 @@ from ddb.utils.table_display import get_table_display from .. import features from ...action.action import EventBinding +from ...action.runner import FailFastError, ExpectedError from ...command import Command from ...config.flatten import flatten from ...context import context @@ -104,8 +105,9 @@ def _build_update_header(last_release): def _build_update_details(github_repository, last_release): row = [] - if is_binary(): - row.append('run "ddb self-update" command to update.') + update_tip = _build_update_tip() + if update_tip: + row.append(update_tip) row.extend(( 'For more information, check the following links:', 'https://github.com/{}/releases/tag/{}'.format(github_repository, last_release), @@ -114,6 +116,12 @@ def _build_update_details(github_repository, last_release): return row +def _build_update_tip(): + if is_binary(): + return 'run "ddb self-update" command to update.' + return '' + + def is_binary(): """ Check if current process is binary. @@ -304,7 +312,7 @@ def execute(silent: bool): print_version(github_repository, silent) -class MainCheckForUpdateAction(InitializableAction): +class CheckForUpdateAction(InitializableAction): """ Check if a new version is available on github. """ @@ -339,6 +347,49 @@ def execute(command: Command): cache.set('last_check', today) +class RequiredVersionError(FailFastError, ExpectedError): + """ + Exception that should be raised when the current version doesn't fullfil the required one. + """ + + def log_error(self): + context.log.error(str(self)) + + +class CheckRequiredVersion(Action): + """ + Check if a new version is available on github. + """ + + @property + def name(self) -> str: + return "core:check-required-version" + + @property + def event_bindings(self): + return events.main.start + + @staticmethod + def execute(command: Command): + """ + Check for updates + :param command command name + :return: + """ + if command.name not in ['self-update']: + required_version = config.data.get('core.required_version') + if not required_version: + return + if required_version > get_current_version(): + update_tip = _build_update_tip() + if update_tip: + update_tip = ' ' + update_tip + raise RequiredVersionError( + "This project requires ddb {}+. Current version is {}.{}".format(required_version, + get_current_version(), + update_tip)) + + class SelfUpdateAction(Action): """ Self update ddb if a newer version is available. diff --git a/ddb/feature/core/schema.py b/ddb/feature/core/schema.py index 69c04f7e..6d954d5e 100644 --- a/ddb/feature/core/schema.py +++ b/ddb/feature/core/schema.py @@ -66,3 +66,4 @@ class CoreFeatureSchema(FeatureSchema): process = fields.Dict(fields.String(), fields.Nested(ProcessSchema()), default={}) # Process binary mappings configuration = fields.Nested(ConfigurationSchema(), default=ConfigurationSchema()) github_repository = fields.String(required=True, default="gfi-centre-ouest/docker-devbox-ddb") + required_version = fields.String(required=False, allow_none=True, default=None) diff --git a/docs/features/core.md b/docs/features/core.md index 17c0fb25..89971195 100644 --- a/docs/features/core.md +++ b/docs/features/core.md @@ -42,6 +42,12 @@ Feature configuration - `project.name`: The name of the project - type: string - default: +- `required_version`: The minimal required ddb version for the project to work properly. If the required version is higher than the currently running one, ddb will stop working until it's updated. + - type: string + - default: null +- `github_repository`: Github repository used to check new release of ddb. Should not be changed. + - type: string + - default: 'gfi-centre-ouest/docker-devbox-ddb' !!! example "Configuration" ```yaml @@ -57,6 +63,7 @@ Feature configuration - ci - dev current: dev + github_repository: gfi-centre-ouest/docker-devbox-ddb os: posix path: ddb_home: /home/devbox/.docker-devbox/ddb diff --git a/tests/it/test_core.data/required-version/ddb.yml b/tests/it/test_core.data/required-version/ddb.yml new file mode 100644 index 00000000..61611f44 --- /dev/null +++ b/tests/it/test_core.data/required-version/ddb.yml @@ -0,0 +1,2 @@ +core: + required_version: 1.3.0 \ No newline at end of file diff --git a/tests/it/test_core.data/required-version/test.jinja b/tests/it/test_core.data/required-version/test.jinja new file mode 100644 index 00000000..e6f20eca --- /dev/null +++ b/tests/it/test_core.data/required-version/test.jinja @@ -0,0 +1 @@ +{{core.env.current}} \ No newline at end of file diff --git a/tests/it/test_core.py b/tests/it/test_core.py index e2bcc459..90905374 100644 --- a/tests/it/test_core.py +++ b/tests/it/test_core.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from _pytest.capture import CaptureFixture @@ -18,7 +19,7 @@ def test_self_update_no_binary(self, project_loader, capsys: CaptureFixture, moc main(["self-update"]) - assert Path('./bin/ddb').read_text() == "binary" + assert Path('./bin/ddb').read_bytes() == b"binary" outerr = capsys.readouterr() assert outerr.err == "" @@ -119,7 +120,6 @@ def test_version_outdated_binary(self, project_loader, capsys: CaptureFixture, m main(["--version"]) outerr = capsys.readouterr() - print(outerr.out) assert outerr.err == "" assert outerr.out == '''+---------------------------------------------------------------------------------------+ | ddb 1.3.0 | @@ -134,3 +134,56 @@ def test_version_outdated_binary(self, project_loader, capsys: CaptureFixture, m | Please report any bug or feature request at | | https://github.com/gfi-centre-ouest/docker-devbox-ddb/issues | +---------------------------------------------------------------------------------------+\n''' + + def test_required_version_eq(self, project_loader, capsys: CaptureFixture, mocker: MockerFixture): + mocker.patch('ddb.feature.core.actions.get_current_version', lambda *args, **kwargs: '1.3.0') + + project_loader("required-version") + + exceptions = main(["configure"]) + + assert not exceptions + assert os.path.exists('test') + + def test_required_version_lt(self, project_loader, capsys: CaptureFixture, mocker: MockerFixture): + mocker.patch('ddb.feature.core.actions.get_current_version', lambda *args, **kwargs: '1.2.9') + + project_loader("required-version") + + exceptions = main(["configure"]) + assert len(exceptions) == 1 + assert str(exceptions[0]) == "This project requires ddb 1.3.0+. Current version is 1.2.9." + + outerr = capsys.readouterr() + assert "This project requires ddb 1.3.0+. Current version is 1.2.9." in outerr.err + assert outerr.out == "" + + assert not os.path.exists('test') + + def test_required_version_lt_binary(self, project_loader, capsys: CaptureFixture, mocker: MockerFixture): + mocker.patch('ddb.feature.core.actions.is_binary', lambda *args, **kwargs: True) + mocker.patch('ddb.feature.core.actions.get_current_version', lambda *args, **kwargs: '1.2.9') + + project_loader("required-version") + + exceptions = main(["configure"]) + assert len(exceptions) == 1 + assert str(exceptions[0]) == "This project requires ddb 1.3.0+. " \ + "Current version is 1.2.9. " \ + "run \"ddb self-update\" command to update." + + outerr = capsys.readouterr() + assert "This project requires ddb 1.3.0+. Current version is 1.2.9." in outerr.err + assert outerr.out == "" + + assert not os.path.exists('test') + + def test_required_version_gt(self, project_loader, capsys: CaptureFixture, mocker: MockerFixture): + mocker.patch('ddb.feature.core.actions.get_current_version', lambda *args, **kwargs: '1.3.1') + + project_loader("required-version") + + exceptions = main(["configure"]) + + assert not exceptions + assert os.path.exists('test')