Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: import rules from LD flags #3233

Merged
merged 22 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion api/integrations/launch_darkly/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _iter_paginated_items(
self,
collection_endpoint: str,
additional_params: Optional[dict[str, str]] = None,
) -> Iterator[dict[str, Any]]:
) -> Iterator[dict[str, Any] | str]:
params = {"limit": LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE}
if additional_params:
params.update(additional_params)
Expand Down Expand Up @@ -82,6 +82,7 @@ def get_flags(self, project_key: str) -> list[ld_types.FeatureFlag]:
return list(
self._iter_paginated_items(
collection_endpoint=endpoint,
additional_params={"summary": "0"},
)
)

Expand All @@ -106,3 +107,24 @@ def get_flag_tags(self) -> list[str]:
additional_params={"kind": "flag"},
)
)

def get_segment_tags(self) -> list[str]:
"""operationId: getTags"""
endpoint = "/api/v2/tags"
return list(
self._iter_paginated_items(
collection_endpoint=endpoint,
additional_params={"kind": "segment"},
)
)

def get_segments(
self, project_key: str, environment_key: str
) -> list[ld_types.UserSegment]:
"""operationId: getSegments"""
endpoint = f"/api/v2/segments/{project_key}/{environment_key}"
return list(
self._iter_paginated_items(
collection_endpoint=endpoint,
)
)
238 changes: 229 additions & 9 deletions api/integrations/launch_darkly/services.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import logging
import re
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, Tuple
from typing import Callable, Optional, Tuple

from django.core import signing
from django.utils import timezone
from flag_engine.segments import constants
from requests.exceptions import RequestException

from environments.models import Environment
from features.feature_types import MULTIVARIATE, STANDARD, FeatureType
from features.models import Feature, FeatureState, FeatureStateValue
from features.models import (
Feature,
FeatureSegment,
FeatureState,
FeatureStateValue,
)
from features.multivariate.models import (
MultivariateFeatureOption,
MultivariateFeatureStateValue,
Expand All @@ -23,11 +31,13 @@
LaunchDarklyImportRequest,
LaunchDarklyImportStatus,
)
from integrations.launch_darkly.types import Clause
from projects.models import Project
from projects.tags.models import Tag
from segments.models import Condition, Segment, SegmentRule
from users.models import FFAdminUser

if TYPE_CHECKING: # pragma: no cover
from projects.models import Project
from users.models import FFAdminUser
logger = logging.getLogger(__name__)


def _sign_ld_value(value: str, user_id: int) -> str:
Expand Down Expand Up @@ -109,15 +119,156 @@ def _create_tags_from_ld(
return tags_by_ld_tag


# Based on: https://docs.launchdarkly.com/sdk/concepts/flag-evaluation-rules#operators
def _ld_operator_to_flagsmith_operator(ld_operator: str) -> Optional[str]:
return {
"in": constants.IN,
"endsWith": constants.REGEX,
"startsWith": constants.REGEX,
"matches": constants.REGEX,
"contains": constants.CONTAINS,
"lessThan": constants.LESS_THAN,
"lessThanOrEqual": constants.LESS_THAN_INCLUSIVE,
"greaterThan": constants.GREATER_THAN,
"greaterThanOrEqual": constants.GREATER_THAN_INCLUSIVE,
"before": constants.LESS_THAN,
"after": constants.GREATER_THAN,
"semVerEqual": constants.EQUAL,
"semVerLessThan": constants.LESS_THAN,
"semVerGreaterThan": constants.GREATER_THAN,
}.get(ld_operator, None)


# TODO: Not sure what happens if it is not `IN` and there are multiple values.
def _convert_ld_value(values: list[str], ld_operator: str) -> str:
match ld_operator:
case "in":
return ",".join(values)
case "endsWith":
return ".*" + re.escape(values[0])
case "startsWith":
return re.escape(values[0]) + ".*"
case "matches" | "segmentMatch":
return re.escape(values[0]).replace("\\*", ".*")
case "contains":
return ".*" + re.escape(values[0]) + ".*"
case _:
return values[0]


def _create_segment_from_clauses(
clauses: list[Clause],
project: Project,
feature: Feature,
environment: Environment,
name: str,
) -> Segment:
segment = Segment.objects.create(name=name, project=project, feature=feature)

# A parent rule has two children: one for the regular conditions and multiple for the negated conditions
# Mathematically, this is equivalent to:
# any(X, Y, !Z) = any(any(X,Y), none(Z))
# Or with more parameters,
# any(X, Y, !Z, !W) = any(any(X,Y), none(Z), none(W))
# Here, any(X,Y) is the child rule. none(Z) and none(W) are the negated children.
# Since there is no !X operation in Flagsmith, we wrap negated conditions in a none() rule.
parent_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ANY_RULE)
child_rule = SegmentRule.objects.create(rule=parent_rule, type=SegmentRule.ANY_RULE)

for clause in clauses:
operator = _ld_operator_to_flagsmith_operator(clause["op"])
value = _convert_ld_value(clause["values"], clause["op"])
_property = clause["attribute"]

if operator is not None:
if clause["negate"] is True:
negated_child = SegmentRule.objects.create(
rule=parent_rule, type=SegmentRule.NONE_RULE
)
rule = negated_child
else:
rule = child_rule

condition = Condition.objects.update_or_create(
rule=rule,
property=_property,
value=value,
operator=operator,
created_with_segment=True,
)
logger.warning("Condition created: " + str(condition))
elif clause["op"] == "segmentMatch":
# TODO: Assign the segment to the feature
pass
else:
logger.warning(
"Can't map launch darkly operator: " + clause["op"] + ", skipping..."
)

feature_segment = FeatureSegment.objects.create(
feature=feature,
segment=segment,
environment=environment,
)

# Enable rules by default. In LD, rules are enabled if the flag is on.
FeatureState.objects.update_or_create(
feature=feature,
feature_segment=feature_segment,
environment=environment,
defaults={"enabled": True},
)
return segment


def _import_targets(
ld_flag_config: ld_types.FeatureFlagConfig,
feature: Feature,
environment: Environment,
) -> None:
if "targets" in ld_flag_config:
logger.warning("Targets: " + str(ld_flag_config["targets"]))

if "contextTargets" in ld_flag_config:
logger.warning("Context targets: " + str(ld_flag_config["contextTargets"]))


def _import_rules(
ld_flag_config: ld_types.FeatureFlagConfig,
feature: Feature,
environment: Environment,
variations_by_idx: dict[str, ld_types.Variation],
) -> None:
if "rules" in ld_flag_config and len(ld_flag_config["rules"]) > 0:
logger.warning("Rules: " + str(ld_flag_config["rules"]))
for index, rule in enumerate(ld_flag_config["rules"]):
variation = variations_by_idx[str(rule["variation"])]["value"]
description = rule.get("description", "Unknown")

logger.warning("Rule found: " + description + " : " + str(variation))
_create_segment_from_clauses(
rule["clauses"],
feature.project,
feature,
environment,
"imported-" + str(index),
)


def _create_boolean_feature_states(
ld_flag: ld_types.FeatureFlag,
feature: Feature,
environments_by_ld_environment_key: dict[str, Environment],
) -> None:
variations_by_idx = {
str(idx): variation for idx, variation in enumerate(ld_flag["variations"])
}

for ld_environment_key, environment in environments_by_ld_environment_key.items():
ld_flag_config = ld_flag["environments"][ld_environment_key]
feature_state, _ = FeatureState.objects.update_or_create(
feature=feature,
feature_segment=None,
environment=environment,
defaults={"enabled": ld_flag_config["on"]},
)
Expand All @@ -126,6 +277,9 @@ def _create_boolean_feature_states(
feature_state=feature_state,
)

_import_targets(ld_flag_config, feature, environment)
_import_rules(ld_flag_config, feature, environment, variations_by_idx)


def _create_string_feature_states(
ld_flag: ld_types.FeatureFlag,
Expand Down Expand Up @@ -156,21 +310,29 @@ def _create_string_feature_states(

feature_state, _ = FeatureState.objects.update_or_create(
feature=feature,
feature_segment=None,
environment=environment,
defaults={"enabled": is_flag_on},
)

FeatureStateValue.objects.update_or_create(
feature_state=feature_state,
defaults={"type": STRING, "string_value": string_value},
)

_import_targets(ld_flag_config, feature, environment)
_import_rules(ld_flag_config, feature, environment, variations_by_idx)


def _create_mv_feature_states(
ld_flag: ld_types.FeatureFlag,
feature: Feature,
environments_by_ld_environment_key: dict[str, Environment],
) -> None:
variations = ld_flag["variations"]
variations_by_idx = {
str(idx): variation for idx, variation in enumerate(variations)
}
variation_values_by_idx: dict[str, str] = {}
mv_feature_options_by_variation: dict[str, MultivariateFeatureOption] = {}

Expand All @@ -194,6 +356,7 @@ def _create_mv_feature_states(

feature_state, _ = FeatureState.objects.update_or_create(
feature=feature,
feature_segment=None,
environment=environment,
defaults={"enabled": is_flag_on},
)
Expand Down Expand Up @@ -239,6 +402,9 @@ def _create_mv_feature_states(
defaults={"percentage_allocation": percentage_allocation},
)

_import_targets(ld_flag_config, feature, environment)
_import_rules(ld_flag_config, feature, environment, variations_by_idx)


def _get_feature_type_and_feature_state_factory(
ld_flag: ld_types.FeatureFlag,
Expand Down Expand Up @@ -316,6 +482,38 @@ def _create_features_from_ld(
]


def _create_segments_from_ld(
ld_segments: list[tuple[ld_types.UserSegment, str]],
tags_by_ld_tag: dict[str, Tag],
project_id: int,
) -> list[Segment]:
"""

:param ld_segments: A list of mapping from (env, segment).
"""
for ld_segment, env in ld_segments:
if ld_segment["deleted"]:
continue

Segment.objects.create(
name="imported-" + env + "-" + ld_segment["name"],
project_id=project_id,
)

# TODO: Tagging segments is not supported yet.

# TODO: Create rules
logger.warning("Segment rules: " + str(ld_segment["rules"]))

# TODO: Import users
logger.warning("Included segment users: " + str(ld_segment["included"]))
logger.warning("Excluded segment users: " + str(ld_segment["excluded"]))
logger.warning("Included context users: " + str(ld_segment["includedContexts"]))
logger.warning("Excluded context users: " + str(ld_segment["excludedContexts"]))

return []


def create_import_request(
project: "Project",
user: "FFAdminUser",
Expand Down Expand Up @@ -356,7 +554,18 @@ def process_import_request(
try:
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()
ld_flag_tags = ld_client.get_flag_tags()
ld_segment_tags = ld_client.get_segment_tags()
# Keyed by (segment, environment)
ld_segments: list[tuple[ld_types.UserSegment, str]] = []
for env in ld_environments:
ld_segments_for_env = ld_client.get_segments(
project_key=ld_project_key,
environment_key=env["key"],
)
for segment in ld_segments_for_env:
ld_segments.append((segment, env["key"]))

except RequestException as exc:
_log_error(
import_request=import_request,
Expand All @@ -372,13 +581,24 @@ def process_import_request(
ld_environments=ld_environments,
project_id=import_request.project_id,
)
tags_by_ld_tag = _create_tags_from_ld(
ld_tags=ld_tags,

segment_tags_by_ld_tag = _create_tags_from_ld(
ld_tags=ld_segment_tags,
project_id=import_request.project_id,
)
_create_segments_from_ld(
ld_segments=ld_segments,
tags_by_ld_tag=segment_tags_by_ld_tag,
project_id=import_request.project_id,
)

flag_tags_by_ld_tag = _create_tags_from_ld(
ld_tags=ld_flag_tags,
project_id=import_request.project_id,
)
_create_features_from_ld(
ld_flags=ld_flags,
environments_by_ld_environment_key=environments_by_ld_environment_key,
tags_by_ld_tag=tags_by_ld_tag,
tags_by_ld_tag=flag_tags_by_ld_tag,
project_id=import_request.project_id,
)
Loading