def test_with_context(self): with ConsulServiceController().start_service() as service: consul_client = service.create_consul_client() lock_manager = ConsulLockManager(consul_client=consul_client) with lock_manager.acquire(KEY_1) as lock_information: self.assertEqual(lock_information, lock_manager.find(KEY_1)) self.assertIsNone(lock_manager.find(KEY_1))
def test_find_regex_when_no_locks(self): with ConsulServiceController().start_service() as service: lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) found_locks = lock_manager.find_regex( f"{KEY_1}{KEY_DIRECTORY_SEPARATOR}[0-9]+") self.assertEqual(0, len(found_locks))
def find_regex(service: ConsulDockerisedService): consul_client = service.create_consul_client() lock_manager = ConsulLockManager(consul_client=consul_client) consul_client.kv.put(KEYS_1[1], "unrelated") found_locks = lock_manager.find_regex(KEYS_1_REGEX) self.assertEqual(2, len(found_locks)) self.assertIsInstance(found_locks[KEYS_1[0]], ConsulLockInformation) self.assertIsNone(found_locks[KEYS_1[1]])
def test_unlock_all(self): test_keys = [f"{KEY_1}_{i}" for i in range(5)] with ConsulServiceController().start_service() as service: lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) for key in test_keys: lock = lock_manager.acquire(key) assert isinstance(lock, ConsulLockInformation) unlock_results = lock_manager.release_all(test_keys) for unlock_result in unlock_results: self.assertTrue(unlock_result)
def test_unlock_all(self): test_keys = [f"{KEY_1}_{i}" for i in range(5)] with ConsulServiceController().start_service() as service: lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) for key in test_keys: lock = lock_manager.acquire(key) assert isinstance(lock, ConsulLockInformation) unlock_results = lock_manager.release_all(test_keys) for unlock_result in unlock_results: self.assertTrue(unlock_result)
def _release_lock(lock_manager: ConsulLockManager, configuration: CliUnlockConfiguration): """ Unlocks a lock. :param lock_manager: the lock manager :param configuration: the configuration required to unlock the lock """ if configuration.regex_key_enabled: release_information = sorted(list(lock_manager.release_regex(key_regex=configuration.key))) else: release_information = lock_manager.release(key=configuration.key) print(json.dumps(release_information)) exit(SUCCESS_EXIT_CODE)
def _acquire_lock(lock_manager: ConsulLockManager, configuration: CliLockConfiguration) \ -> Optional[ConnectedConsulLockInformation]: """ TODO :param lock_manager: :param configuration: :return: """ event_listeners: LockEventListener = {} if configuration.on_before_locked_executables is not None: event_listeners["on_before_lock"] = _generate_event_listener_caller( configuration.on_before_locked_executables) if configuration.on_lock_already_locked_executables is not None: event_listeners["on_lock_already_locked"] = _generate_event_listener_caller( configuration.on_lock_already_locked_executables) try: return lock_manager.acquire( key=configuration.key, blocking=not configuration.non_blocking, timeout=configuration.timeout, metadata=configuration.metadata, **event_listeners, lock_poll_interval_generator=lambda i: configuration.lock_poll_interval) except LockAcquireTimeoutError as e: logger.debug(e) logger.error(f"Timed out whilst waiting to acquire lock: {configuration.key}") print(json.dumps(None)) exit(LOCK_ACQUIRE_TIMEOUT_EXIT_CODE)
def main(lock_key: str): lock_manager = ConsulLockManager() lock = lock_manager.find(lock_key) logger.debug(f"Lock with key \"{lock_key}\": {lock}") if lock is not None and lock.metadata is not None and JOB_ID_LOCK_METADATA_KEY in lock.metadata: job_id = lock.metadata[JOB_ID_LOCK_METADATA_KEY] logger.info(f"Lock currently held by CI job with ID: {job_id}") job_running = is_ci_job_running(job_id) logger.info(f"CI job with ID {job_id} {'is' if job_running else 'is not'} running") if not job_running: logger.info(f"Releasing lock for {lock.key} held by non-running job {job_id}") released = lock_manager.release(lock.key) logger.info("Released lock!" if released is not None else "Did not manage to release lock (someone else " "probably else released it before me)")
def main(cli_arguments: List[str]): """ Entrypoint. :param cli_arguments: arguments passed in via the CLI :raises SystemExit: always raised """ cli_configuration: CliConfiguration try: cli_configuration = parse_cli_configuration(cli_arguments) except InvalidCliArgumentError as e: logger.error(e) exit(INVALID_CLI_ARGUMENT_EXIT_CODE) except SystemExit as e: exit(e.code) if cli_configuration.log_verbosity: logging.getLogger(PACKAGE_NAME).setLevel(cli_configuration.log_verbosity) consul_configuration: ConsulConfiguration try: consul_configuration = get_consul_configuration_from_environment() except KeyError as e: logger.error(f"Cannot connect to Consul - the environment variable {e.args[0]} must be set") exit(MISSING_REQUIRED_ENVIRONMENT_VARIABLE_EXIT_CODE) except InvalidEnvironmentVariableError as e: logger.error(e) exit(INVALID_ENVIRONMENT_VARIABLE_EXIT_CODE) lock_manager: ConsulLockManager try: lock_manager = ConsulLockManager( consul_configuration=consul_configuration, session_ttl_in_seconds=cli_configuration.session_ttl) except InvalidSessionTtlValueError as e: logger.error(e) exit(INVALID_SESSION_TTL_EXIT_CODE) try: { CliLockConfiguration: _acquire_lock_and_exit, CliLockAndExecuteConfiguration: _acquire_lock_and_execute, CliUnlockConfiguration: _release_lock }[type(cli_configuration)](lock_manager, cli_configuration) except PermissionDeniedConsulError as e: error_message = f"Invalid credentials - are you sure you have set {CONSUL_TOKEN_ENVIRONMENT_VARIABLE} " \ f"correctly?" logger.debug(e) logger.error(error_message) exit(PERMISSION_DENIED_EXIT_CODE) except DoubleSlashKeyError as e: logger.debug(e) logger.error(f"Double slashes \"//\" in keys get converted into single slashes \"/\" - please use a " f"single slash if this is intended: {cli_configuration.key}") exit(INVALID_KEY_EXIT_CODE) except NonNormalisedKeyError as e: logger.debug(e) logger.error(f"Key paths must be normalised - use \"{normpath(e.key)}\" if this key was intended: " f"{cli_configuration.key}") exit(INVALID_KEY_EXIT_CODE)
def __init__(self, data_key: str, lock_key: str, url: str = None, token: str = None, consul_client=None, configuration_checksum_mappings: Mapping[str, str] = None): Consul = ConsulChecksumStorage._load_consul_class() ConsulLockManager = ConsulChecksumStorage._load_consul_lock_manager() if url is not None and consul_client is not None: raise ValueError("Cannot use both `url` and `consul_client`") self.data_key = data_key self.lock_key = lock_key consul_client_kwargs: Dict = {} if url is not None: parsed_url = urlparse(url) consul_client_kwargs["host"] = parsed_url.hostname consul_client_kwargs["port"] = parsed_url.port consul_client_kwargs["scheme"] = parsed_url.scheme if len( parsed_url.scheme) > 0 else "http" self._consul_client = consul_client if consul_client is not None else Consul( **consul_client_kwargs) if token is None: token = os.environ.get( ConsulChecksumStorage.CONSUL_HTTP_TOKEN_ENVIRONMENT_VARIABLE, None) if token is not None: # Work around for https://github.com/cablehead/python-consul/issues/170 self._consul_client.token = token self._consul_client.http.session.headers.update( {"X-Consul-Token": token}) self._lock_manager = ConsulLockManager( consul_client=self._consul_client, session_ttl_in_seconds=ConsulChecksumStorage. CONSUL_SESSION_LOCK_DEFAULT_TIMEOUT) super().__init__(configuration_checksum_mappings)
def _acquire_lock_and_execute(lock_manager: ConsulLockManager, configuration: CliLockAndExecuteConfiguration): """ Executes whilst holding a lock, exiting after the execute returns with the executables return code. :param lock_manager: the lock manager :param configuration: the configuration """ lock = _acquire_lock(lock_manager, configuration) if lock is None: exit(UNABLE_TO_ACQUIRE_LOCK_EXIT_CODE) return_code, _, _ = lock_manager.execute_with_lock(configuration.executable, lock) exit(return_code)
def action_executor(key: str, service: ConsulDockerisedService) -> CaptureResult: nonlocal init_args_generator, init_kwargs_generator, action_args, action_kwargs, action_property lock_manager = ConsulLockManager( *init_args_generator(key, service), **init_kwargs_generator(key, service)) action_args = action_args if action_args is not None else [] action_kwargs = action_kwargs if action_kwargs is not None else {} return TestConsulLockManager._CAPTURE_WRAP_BUILDER.build( getattr(lock_manager, action_property))(key, *action_args, **action_kwargs)
def main(lock_key: str): lock_manager = ConsulLockManager() lock = lock_manager.find(lock_key) logger.debug(f"Lock with key \"{lock_key}\": {lock}") if lock is not None and lock.metadata is not None and JOB_ID_LOCK_METADATA_KEY in lock.metadata: job_id = lock.metadata[JOB_ID_LOCK_METADATA_KEY] logger.info(f"Lock currently held by CI job with ID: {job_id}") job_running = is_ci_job_running(job_id) logger.info( f"CI job with ID {job_id} {'is' if job_running else 'is not'} running" ) if not job_running: logger.info( f"Releasing lock for {lock.key} held by non-running job {job_id}" ) released = lock_manager.release(lock.key) logger.info("Released lock!" if released is not None else "Did not manage to release lock (someone else " "probably else released it before me)")
def test_find_when_no_locks(self): with ConsulServiceController().start_service() as service: lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) self.assertIsNone(lock_manager.find(KEY_1))
def test_can_get_configuration_from_environment(self): with ConsulServiceController().start_service() as service: set_consul_env(service) lock_manager = ConsulLockManager() self.assertIsNone(lock_manager.release(KEY_1))
def find(service: ConsulDockerisedService): lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) found_lock = lock_manager.find(KEYS_1[0]) self.assertEqual(KEYS_1[0], found_lock.key)
def find_regex(service: ConsulDockerisedService): lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) found_locks = lock_manager.find_regex(KEYS_1_REGEX) self.assertCountEqual(KEYS_1, [lock.key for lock in found_locks.values()])
def test_lock_with_non_normalised_path(self): lock_manager = ConsulLockManager( consul_configuration=_DUMMY_CONSUL_CONFIGURATION) self.assertRaises(NonNormalisedKeyError, lock_manager.acquire, NON_NORMALISED_KEY)
def test_cannot_use_after_teardown(self): lock_manager = ConsulLockManager(consul_configuration=_DUMMY_CONSUL_CONFIGURATION) lock_manager.teardown() self.assertRaises(UnusableStateError, lock_manager.acquire)
def find_regex(service: ConsulDockerisedService): lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) found_locks = lock_manager.find_regex(KEYS_1_REGEX) self.assertCountEqual(KEYS_1, [lock.key for lock in found_locks.values()])
def release(service: ConsulDockerisedService): lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) released_locks = lock_manager.release_regex(KEYS_1_REGEX) self.assertCountEqual(KEYS_1, released_locks)
def test_lock_with_double_slash_path(self): lock_manager = ConsulLockManager( consul_configuration=_DUMMY_CONSUL_CONFIGURATION) self.assertRaises(DoubleSlashKeyError, lock_manager.acquire, DOUBLE_SLASH_KEY)
class ConsulChecksumStorage(ChecksumStorage): """ Consul storage for configuration -> checksum mappings. """ CONSUL_HTTP_TOKEN_ENVIRONMENT_VARIABLE = "CONSUL_HTTP_TOKEN" CONSUL_SESSION_LOCK_DEFAULT_TIMEOUT = 120 TEXT_ENCODING = "utf-8" _IMPORT_MISSING_ERROR_MESSAGE = "To use Consul storage, please install the requirements in " \ "`consul_requirements.txt`" @staticmethod def _load_consul_class() -> Type: """ Loads the Consul class at run time (optional requirement). :return: the Consul class :raises MissingOptionalDependencyError: if a required dependency is not installed """ try: from consul import Consul except ImportError as e: raise MissingOptionalDependencyError( ConsulChecksumStorage._IMPORT_MISSING_ERROR_MESSAGE) from e return Consul @staticmethod def _load_consul_lock_manager() -> Type: """ Loads the ConsulLockManager class at run time (optional requirement). :return: the Consul class :raises MissingOptionalDependencyError: if a required dependency is not installed """ try: from consullock.managers import ConsulLockManager except ImportError as e: raise MissingOptionalDependencyError( ConsulChecksumStorage._IMPORT_MISSING_ERROR_MESSAGE) from e return ConsulLockManager @property def url(self) -> str: return self._consul_client.http.base_uri @property def token(self) -> str: return self._consul_client.token def __init__(self, data_key: str, lock_key: str, url: str = None, token: str = None, consul_client=None, configuration_checksum_mappings: Mapping[str, str] = None): Consul = ConsulChecksumStorage._load_consul_class() ConsulLockManager = ConsulChecksumStorage._load_consul_lock_manager() if url is not None and consul_client is not None: raise ValueError("Cannot use both `url` and `consul_client`") self.data_key = data_key self.lock_key = lock_key consul_client_kwargs: Dict = {} if url is not None: parsed_url = urlparse(url) consul_client_kwargs["host"] = parsed_url.hostname consul_client_kwargs["port"] = parsed_url.port consul_client_kwargs["scheme"] = parsed_url.scheme if len( parsed_url.scheme) > 0 else "http" self._consul_client = consul_client if consul_client is not None else Consul( **consul_client_kwargs) if token is None: token = os.environ.get( ConsulChecksumStorage.CONSUL_HTTP_TOKEN_ENVIRONMENT_VARIABLE, None) if token is not None: # Work around for https://github.com/cablehead/python-consul/issues/170 self._consul_client.token = token self._consul_client.http.session.headers.update( {"X-Consul-Token": token}) self._lock_manager = ConsulLockManager( consul_client=self._consul_client, session_ttl_in_seconds=ConsulChecksumStorage. CONSUL_SESSION_LOCK_DEFAULT_TIMEOUT) super().__init__(configuration_checksum_mappings) def get_checksum(self, configuration_id: str) -> Optional[str]: return self.get_all_checksums().get(configuration_id) def get_all_checksums(self) -> Dict[str, str]: value = self._consul_client.kv.get(self.data_key)[1] if value is None: return {} value = value["Value"].decode(ConsulChecksumStorage.TEXT_ENCODING) return json.loads(value) def set_checksum(self, configuration_id: str, checksum: str): with self._lock_manager.acquire(self.lock_key): value = self.get_all_checksums() value[configuration_id] = checksum self._consul_client.kv.put(self.data_key, json.dumps(value, sort_keys=True)) def set_all_checksums(self, configuration_checksum_mappings: Mapping[str, str]): with self._lock_manager.acquire(self.lock_key): value = self.get_all_checksums() value.update(configuration_checksum_mappings) self._consul_client.kv.put(self.data_key, json.dumps(value, sort_keys=True))
def first_locker(key: str, service: ConsulDockerisedService) -> CaptureResult: lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) lock_information = lock_manager.acquire(KEY_1) return CaptureResult(return_value=lock_information)
def test_find_when_no_locks(self): with ConsulServiceController().start_service() as service: lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) self.assertIsNone(lock_manager.find(KEY_1))
def test_cannot_use_after_teardown(self): lock_manager = ConsulLockManager( consul_configuration=_DUMMY_CONSUL_CONFIGURATION) lock_manager.teardown() self.assertRaises(UnusableStateError, lock_manager.acquire)
def find(service: ConsulDockerisedService): lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) found_lock = lock_manager.find(KEYS_1[0]) self.assertEqual(KEYS_1[0], found_lock.key)
def release(service: ConsulDockerisedService): lock_manager = ConsulLockManager( consul_client=service.create_consul_client()) released_locks = lock_manager.release_regex(KEYS_1_REGEX) self.assertCountEqual(KEYS_1, released_locks)
def test_find_regex_when_no_locks(self): with ConsulServiceController().start_service() as service: lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) found_locks = lock_manager.find_regex(f"{KEY_1}{KEY_DIRECTORY_SEPARATOR}[0-9]+") self.assertEqual(0, len(found_locks))
def first_locker(key: str, service: ConsulDockerisedService) -> CaptureResult: lock_manager = ConsulLockManager(consul_client=service.create_consul_client()) lock_information = lock_manager.acquire(KEY_1) return CaptureResult(return_value=lock_information)