diff --git a/api/integrations/launch_darkly/services.py b/api/integrations/launch_darkly/services.py index 0820d98b607a..8f74e20d51de 100644 --- a/api/integrations/launch_darkly/services.py +++ b/api/integrations/launch_darkly/services.py @@ -3,7 +3,7 @@ from django.core import signing from django.utils import timezone -from requests.exceptions import HTTPError, RequestException +from requests.exceptions import RequestException from environments.models import Environment from features.feature_types import MULTIVARIATE, STANDARD, FeatureType @@ -41,6 +41,13 @@ def _unsign_ld_value(value: str, user_id: int) -> str: ) +def _log_error( + import_request: LaunchDarklyImportRequest, + error_message: str, +) -> None: + import_request.status["error_message"] = error_message + + @contextmanager def _complete_import_request( import_request: LaunchDarklyImportRequest, @@ -164,24 +171,31 @@ def _create_mv_feature_states( environments_by_ld_environment_key: dict[str, Environment], ) -> None: variations = ld_flag["variations"] + variation_values_by_idx: dict[str, str] = {} mv_feature_options_by_variation: dict[str, MultivariateFeatureOption] = {} for idx, variation in enumerate(variations): + variation_idx = str(idx) + variation_value = variation["value"] + variation_values_by_idx[variation_idx] = variation_value ( mv_feature_options_by_variation[str(idx)], _, ) = MultivariateFeatureOption.objects.update_or_create( feature=feature, - string_value=variation["value"], + string_value=variation_value, defaults={"default_percentage_allocation": 0, "type": STRING}, ) for ld_environment_key, environment in environments_by_ld_environment_key.items(): ld_flag_config = ld_flag["environments"][ld_environment_key] + + is_flag_on = ld_flag_config["on"] + feature_state, _ = FeatureState.objects.update_or_create( feature=feature, environment=environment, - defaults={"enabled": ld_flag_config["on"]}, + defaults={"enabled": is_flag_on}, ) cumulative_rollout = rollout_baseline = 0 @@ -189,6 +203,17 @@ def _create_mv_feature_states( if ld_flag_config_summary := ld_flag_config.get("_summary"): enabled_variations = ld_flag_config_summary.get("variations") or {} for variation_idx, variation_config in enabled_variations.items(): + if variation_config.get("isOff"): + # Set LD's off value as the control value. + # We expect only one off variation. + FeatureStateValue.objects.update_or_create( + feature_state=feature_state, + defaults={ + "type": STRING, + "string_value": variation_values_by_idx[variation_idx], + }, + ) + mv_feature_option = mv_feature_options_by_variation[variation_idx] percentage_allocation = 0 @@ -298,13 +323,12 @@ def create_import_request( ld_token: str, ) -> LaunchDarklyImportRequest: ld_client = LaunchDarklyClient(ld_token) - ld_project = ld_client.get_project(project_key=ld_project_key) - requested_environment_count = ld_project["environments"]["totalCount"] + ld_project = ld_client.get_project(project_key=ld_project_key) requested_flag_count = ld_client.get_flag_count(project_key=ld_project_key) status: LaunchDarklyImportStatus = { - "requested_environment_count": requested_environment_count, + "requested_environment_count": ld_project["environments"]["totalCount"], "requested_flag_count": requested_flag_count, } @@ -333,15 +357,15 @@ def process_import_request( ld_environments = ld_client.get_environments(project_key=ld_project_key) ld_flags = ld_client.get_flags(project_key=ld_project_key) ld_tags = ld_client.get_flag_tags() - except HTTPError as exc: - import_request.status[ - "error_message" - ] = f"HTTP {exc.response.status_code} when requesting LaunchDarkly" - raise except RequestException as exc: - import_request.status[ - "error_message" - ] = f"{exc.__class__.__name__} when requesting LaunchDarkly" + _log_error( + import_request=import_request, + error_message=( + f"{exc.__class__.__name__} " + f"{str(exc.response.status_code) + ' ' if exc.response else ''}" + f"when requesting {exc.request.path_url}" + ), + ) raise environments_by_ld_environment_key = _create_environments_from_ld( diff --git a/api/integrations/launch_darkly/tasks.py b/api/integrations/launch_darkly/tasks.py index e4c63f7f154d..833754fd8794 100644 --- a/api/integrations/launch_darkly/tasks.py +++ b/api/integrations/launch_darkly/tasks.py @@ -1,5 +1,3 @@ -from django.db import transaction - from integrations.launch_darkly.models import LaunchDarklyImportRequest from integrations.launch_darkly.services import process_import_request from task_processor.decorators import register_task_handler @@ -7,6 +5,5 @@ @register_task_handler() def process_launch_darkly_import_request(import_request_id: int) -> None: - with transaction.atomic(): - import_request = LaunchDarklyImportRequest.objects.get(id=import_request_id) - process_import_request(import_request) + import_request = LaunchDarklyImportRequest.objects.get(id=import_request_id) + process_import_request(import_request) diff --git a/api/tests/unit/integrations/launch_darkly/test_services.py b/api/tests/unit/integrations/launch_darkly/test_services.py index 46d4fd8ebb06..c5cbbacca382 100644 --- a/api/tests/unit/integrations/launch_darkly/test_services.py +++ b/api/tests/unit/integrations/launch_darkly/test_services.py @@ -1,9 +1,8 @@ -from typing import Type from unittest.mock import MagicMock import pytest from django.core import signing -from requests.exceptions import HTTPError, Timeout +from requests.exceptions import HTTPError, RequestException, Timeout from environments.models import Environment from features.models import Feature, FeatureState @@ -60,20 +59,21 @@ def test_create_import_request__return_expected( [ ( HTTPError(response=MagicMock(status_code=503)), - "HTTP 503 when requesting LaunchDarkly", + "HTTPError 503 when requesting /expected_path", ), - (Timeout(), "Timeout when requesting LaunchDarkly"), + (Timeout(), "Timeout when requesting /expected_path"), ], ) def test_process_import_request__api_error__expected_status( ld_client_mock: MagicMock, ld_client_class_mock: MagicMock, failing_ld_client_method_name: str, - exception: Type[Exception], + exception: RequestException, expected_error_message: str, import_request: LaunchDarklyImportRequest, ) -> None: # Given + exception.request = MagicMock(path_url="/expected_path") getattr(ld_client_mock, failing_ld_client_method_name).side_effect = exception # When @@ -187,6 +187,12 @@ def test_process_import_request__success__expected_status( } assert percentage_mv_feature_states_by_env_name["Test"].enabled is False + + # The `off` variation from LD's environment is imported as the control value. + assert ( + percentage_mv_feature_states_by_env_name["Test"].get_feature_state_value() + == "variation2" + ) assert list( percentage_mv_feature_states_by_env_name[ "Test" @@ -197,6 +203,12 @@ def test_process_import_request__success__expected_status( ) == [("variation1", 100), ("variation2", 0), ("variation3", 0)] assert percentage_mv_feature_states_by_env_name["Production"].enabled is True + + # The `off` variation from LD's environment is imported as the control value. + assert ( + percentage_mv_feature_states_by_env_name["Production"].get_feature_state_value() + == "variation3" + ) assert list( percentage_mv_feature_states_by_env_name[ "Production" diff --git a/docs/docs/integrations/importers/launchdarkly.md b/docs/docs/integrations/importers/launchdarkly.md index 1bbfad4b6d77..4b231f6c79f9 100644 --- a/docs/docs/integrations/importers/launchdarkly.md +++ b/docs/docs/integrations/importers/launchdarkly.md @@ -15,6 +15,12 @@ import has finished. ::: +:::caution + +Import operations will overwrite existing environments and flags in your project. + +::: + ## Integration Setup 1. Create a LaunchDarkly Access Token. In LaunchDarkly: Account settings > Authorization > Access tokens. @@ -45,3 +51,5 @@ Boolean values will be taken from the `_summary -> on` field of within LaunchDar Multivariate LaunchDarkly flags will be imported into Flagsmith as MultiVariate Flagsmith flag values. Multivariate values will be taken from the `variations` field of within LaunchDarkly. + +Values set to serve when targeting is off will be imported as control values. diff --git a/frontend/web/components/pages/ImportPage.tsx b/frontend/web/components/pages/ImportPage.tsx index e2f7be5c8ea8..6d793fd98575 100644 --- a/frontend/web/components/pages/ImportPage.tsx +++ b/frontend/web/components/pages/ImportPage.tsx @@ -5,6 +5,7 @@ import { useGetLaunchDarklyProjectImportQuery, } from 'common/services/useLaunchDarklyProjectImport' import AppLoader from 'components/AppLoader' +import InfoMessage from 'components/InfoMessage' type ImportPageType = { projectId: string @@ -83,6 +84,9 @@ const ImportPage: FC = ({ projectId, projectName }) => { )}
+ + Import operations will overwrite existing environments and flags in your project. +
Import LaunchDarkly Projects