Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 1 addition & 4 deletions api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,13 @@ generate-grafana-client-types:
.PHONY: integrate-private-tests
integrate-private-tests:
$(eval WORKFLOW_REVISION := $(shell grep -A 1 "\[tool.poetry.group.workflows.dependencies\]" pyproject.toml | awk -F '"' '{printf $$4}'))
$(eval TASK_PROCESSOR_REVISION := $(shell grep -A 0 "flagsmith-task-processor" pyproject.toml | awk -F '"' '{printf $$4}'))
$(eval AUTH_CONTROLLER_REVISION := $(shell grep -A 1 "\[tool.poetry.group.auth-controller.dependencies\]" pyproject.toml | awk -F '"' '{printf $$4}'))

git clone https://github.com/flagsmith/flagsmith-saml --depth 1 --branch ${SAML_REVISION} && mv ./flagsmith-saml/tests tests/saml_tests
git clone https://github.com/flagsmith/flagsmith-rbac --depth 1 --branch ${RBAC_REVISION} && mv ./flagsmith-rbac/tests tests/rbac_tests
git clone https://github.com/flagsmith/flagsmith-workflows --depth 1 --branch ${WORKFLOW_REVISION} && mv ./flagsmith-workflows/tests tests/workflow_tests
git clone https://github.com/flagsmith/flagsmith-auth-controller --depth 1 --branch ${AUTH_CONTROLLER_REVISION} && mv ./flagsmith-auth-controller/tests tests/auth_controller_tests

git clone https://github.com/flagsmith/flagsmith-task-processor --depth 1 --branch ${TASK_PROCESSOR_REVISION} && mv ./flagsmith-task-processor/tests tests/task_processor_tests
rm -rf ./flagsmith-saml ./flagsmith-rbac ./flagsmith-workflows ./flagsmith-auth-controller ./flagsmith-task-processor
rm -rf ./flagsmith-saml ./flagsmith-rbac ./flagsmith-workflows ./flagsmith-auth-controller

.PHONY: generate-docs
generate-docs:
Expand Down
1 change: 1 addition & 0 deletions api/integrations/launch_darkly/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6"
LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL = "Imported"
LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL = "Deprecated"

BACKOFF_MAX_RETRIES = 5
BACKOFF_DEFAULT_RETRY_AFTER_SECONDS = 10
1 change: 1 addition & 0 deletions api/integrations/launch_darkly/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
class LaunchDarklyImportStatus(TypedDict):
requested_environment_count: int
requested_flag_count: int
deprecated_flag_count: NotRequired[int]
result: NotRequired[LaunchDarklyImportResult]
error_messages: list[str]

Expand Down
1 change: 1 addition & 0 deletions api/integrations/launch_darkly/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class LaunchDarklyImportRequestStatusSerializer(serializers.Serializer): # type: ignore[type-arg]
requested_environment_count = serializers.IntegerField(read_only=True)
requested_flag_count = serializers.IntegerField(read_only=True)
deprecated_flag_count = serializers.IntegerField(read_only=True, default=0)
result = serializers.ChoiceField(
get_args(LaunchDarklyImportResult),
read_only=True,
Expand Down
16 changes: 14 additions & 2 deletions api/integrations/launch_darkly/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from integrations.launch_darkly.client import LaunchDarklyClient
from integrations.launch_darkly.constants import (
LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL,
LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL,
LAUNCH_DARKLY_IMPORTED_TAG_COLOR,
)
from integrations.launch_darkly.exceptions import LaunchDarklyRateLimitError
Expand Down Expand Up @@ -117,7 +118,11 @@ def _create_tags_from_ld(
) -> dict[str, Tag]:
tags_by_ld_tag = {}

for ld_tag in (*ld_tags, LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL):
for ld_tag in (
*ld_tags,
LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL,
LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL,
):
tags_by_ld_tag[ld_tag], _ = Tag.objects.update_or_create(
label=ld_tag,
project_id=project_id,
Expand Down Expand Up @@ -894,6 +899,8 @@ def _create_feature_from_ld(
tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEFAULT_TAG_LABEL],
*(tags_by_ld_tag[ld_tag] for ld_tag in ld_flag["tags"]),
]
if ld_flag["deprecated"]:
tags.append(tags_by_ld_tag[LAUNCH_DARKLY_IMPORTED_DEPRECATED_TAG_LABEL])

feature, _ = Feature.objects.update_or_create(
project_id=project_id,
Expand All @@ -902,7 +909,7 @@ def _create_feature_from_ld(
"description": ld_flag.get("description"),
"default_enabled": False,
"type": feature_type,
"is_archived": ld_flag["archived"],
"is_archived": ld_flag["archived"] or ld_flag["deprecated"],
},
)
feature.tags.set(tags)
Expand Down Expand Up @@ -1157,3 +1164,8 @@ def process_import_request(
segments_by_ld_key=segments_by_ld_key,
project_id=import_request.project_id,
)

# Count deprecated flags for reporting
import_request.status["deprecated_flag_count"] = sum(
1 for ld_flag in ld_flags if ld_flag["deprecated"]
)
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"offVariation": 1,
"onVariation": 0
},
"deprecated": true,
"description": "",
"environments": {
"production": {
Expand Down Expand Up @@ -162,6 +163,7 @@
"offVariation": 1,
"onVariation": 0
},
"deprecated": false,
"description": "",
"environments": {
"production": {
Expand Down Expand Up @@ -289,6 +291,7 @@
"offVariation": 1,
"onVariation": 0
},
"deprecated": false,
"description": "",
"environments": {
"production": {
Expand Down Expand Up @@ -420,6 +423,7 @@
"offVariation": 1,
"onVariation": 0
},
"deprecated": false,
"description": "",
"environments": {
"production": {
Expand Down Expand Up @@ -558,6 +562,7 @@
"offVariation": 1,
"onVariation": 0
},
"deprecated": false,
"description": "",
"environments": {
"production": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"deprecated_flag_count": 1,
"error_messages": [
"Targeting key 'user-...63a49' exceeds the limit of 1000 characters, skipping for segment 'Large User List (Override for test)'",
"Segment condition value '.*1a6...\\.com' for property 'email' exceeds the limit of 1000 characters, skipping for segment 'Large Dynamic List (Override for test)'",
Expand Down
6 changes: 6 additions & 0 deletions api/tests/unit/integrations/launch_darkly/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,13 @@ def test_process_import_request__success__expected_status( # type: ignore[no-un
("testtag", "#3d4db6"),
("testtag2", "#3d4db6"),
("Imported", "#3d4db6"),
("Deprecated", "#3d4db6"),
}
assert set(
Feature.objects.filter(project=project).values_list("name", "tags__label")
) == {
("flag1", "Imported"),
("flag1", "Deprecated"),
("flag2_value", "Imported"),
("flag3_multivalue", "Imported"),
("flag4_multivalue", "Imported"),
Expand All @@ -158,6 +160,10 @@ def test_process_import_request__success__expected_status( # type: ignore[no-un
("TEST_COMBINED_TARGET", "Imported"),
}

# Deprecated flags are archived.
deprecated_feature = Feature.objects.get(project=project, name="flag1")
assert deprecated_feature.is_archived is True

# Standard feature states have expected values.
boolean_standard_feature = Feature.objects.get(project=project, name="flag1")
boolean_standard_feature_states_by_env_name = {
Expand Down
2 changes: 2 additions & 0 deletions api/tests/unit/integrations/launch_darkly/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def test_launch_darkly_import_request_view__list__return_expected(
"id": import_request.id,
"project": project.id,
"status": {
"deprecated_flag_count": 0,
"error_messages": [],
"requested_environment_count": 2,
"requested_flag_count": 9,
Expand Down Expand Up @@ -92,6 +93,7 @@ def test_launch_darkly_import_request_view__create__return_expected(
"id": created_import_request.id,
"project": project.id,
"status": {
"deprecated_flag_count": 0,
"error_messages": [],
"requested_environment_count": 2,
"requested_flag_count": 9,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,15 @@ Multivariate LaunchDarkly flags will be imported into Flagsmith as MultiVariate
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.

#### Archived and deprecated flags

Archived flags in LaunchDarkly are imported as archived flags in Flagsmith.

Deprecated flags in LaunchDarkly are also imported as archived flags in Flagsmith. They receive a "Deprecated" tag to distinguish them from flags that were archived in LaunchDarkly.

:::note

In both LaunchDarkly and Flagsmith, archived flags continue to evaluate normally for SDKs. Archiving a flag hides it from the dashboard but does not affect evaluation.

:::
1 change: 1 addition & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export type LaunchDarklyProjectImport = {
status: {
requested_environment_count: number
requested_flag_count: number
deprecated_flag_count?: number
result: string | null
error_message: string | null
}
Expand Down
59 changes: 27 additions & 32 deletions frontend/web/components/import-export/ImportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,52 +26,45 @@ const ImportPage: FC<ImportPageType> = ({ projectId, projectName }) => {
const history = useHistory()
const [LDKey, setLDKey] = useState<string>('')
const [importId, setImportId] = useState<number>()
const [importSource, setImportSource] = useState<string>('')
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isAppLoading, setAppIsLoading] = useState<boolean>(false)
const [projects, setProjects] = useState<{ key: string; name: string }[]>([])
const [createLaunchDarklyProjectImport, { data, isSuccess }] =
useCreateLaunchDarklyProjectImportMutation()

const {
data: status,
isSuccess: statusLoaded,
isUninitialized,
refetch,
} = useGetLaunchDarklyProjectImportQuery(
const { data: status } = useGetLaunchDarklyProjectImportQuery(
{
import_id: `${importId}`,
project_id: projectId,
},
{ skip: !importId },
{
pollingInterval: importId ? 1000 : 0,
skip: !importId,
},
)

// Set importId when mutation succeeds
useEffect(() => {
const checkImportStatus = async () => {
setAppIsLoading(true)
const intervalId = setInterval(async () => {
await refetch()

if (statusLoaded && status && status.status.result === 'success') {
clearInterval(intervalId)
setAppIsLoading(false)
window.location.reload()
}
}, 1000)
}

if (statusLoaded) {
checkImportStatus()
if (isSuccess && data?.id) {
setImportId(data.id)
setImportSource('LaunchDarkly')
}
}, [statusLoaded, status, refetch])
}, [isSuccess, data])

// Navigate away on import success
useEffect(() => {
if (isSuccess && data?.id) {
setImportId(data.id)
if (!isUninitialized) {
refetch()
}
if (status?.status?.result === 'success') {
const params = new URLSearchParams()
params.set('import_success', '1')
params.set('import_source', importSource)
params.set('import_count', String(status.status.requested_flag_count))
params.set(
'import_deprecated',
String(status.status.deprecated_flag_count ?? 0),
)
history.push(`/project/${projectId}?${params.toString()}`)
}
}, [isSuccess, data, refetch, isUninitialized])
}, [status, projectId, history, importSource])

const getProjectList = (LDKey: string) => {
setIsLoading(true)
Expand Down Expand Up @@ -210,11 +203,13 @@ const ImportPage: FC<ImportPageType> = ({ projectId, projectName }) => {
</>
)

const isImporting = !!importId && status?.status?.result !== 'success'

return (
<>
{isAppLoading && (
{isImporting && (
<div className='overlay'>
<div className='title'>Importing Project</div>
<div className='title'>Importing from {importSource}...</div>
<AppLoader />
</div>
)}
Expand Down
53 changes: 53 additions & 0 deletions frontend/web/components/import-export/ImportSuccessBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FC, useState } from 'react'
import { useHistory, useLocation, Link } from 'react-router-dom'
import SuccessMessage from 'components/messages/SuccessMessage'
import Utils from 'common/utils/utils'

type ImportSuccessBannerProps = {
projectId: string
environmentId: string
}

const ImportSuccessBanner: FC<ImportSuccessBannerProps> = ({
environmentId,
projectId,
}) => {
const history = useHistory()
const location = useLocation()
const [dismissed, setDismissed] = useState(false)

const params = Utils.fromParam()
const isImportSuccess = params.import_success === '1'
const source = params.import_source
const count = parseInt(params.import_count, 10)
const deprecated = parseInt(params.import_deprecated, 10) || 0

if (!isImportSuccess || !source || !count || dismissed) {
return null
}

const handleDismiss = () => {
setDismissed(true)
history.replace(location.pathname)
}

const archivedLink = `/project/${projectId}/environment/${environmentId}/features?is_archived=true`

return (
<div className='mb-4'>
<SuccessMessage isClosable close={handleDismiss}>
Imported {count} flag{count !== 1 && 's'} from {source}.
{deprecated > 0 && (
<>
{' '}
{deprecated} deprecated flag
{deprecated !== 1 ? 's were' : ' was'}{' '}
<Link to={archivedLink}>archived</Link>.
</>
)}
</SuccessMessage>
</div>
)
}

export default ImportSuccessBanner
8 changes: 4 additions & 4 deletions frontend/web/components/messages/SuccessMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ const SuccessMessage: React.FC<SuccessMessageProps> = ({
const titleDescClass = infoMessageClass ? `${infoMessageClass} body mr-2` : ''

return (
<div className={infoMessageClassName} style={{ ...successStyles }}>
<div className={infoMessageClassName} style={successStyles}>
<span className={`icon-alert ${infoMessageClass} info-icon`}>
<Icon fill='#27AB95' name='checkmark-circle' />
</span>
<div className={titleDescClass}>
<div style={{ fontWeight: 'semi-bold' }}>{title}</div>
<div className='title'>{title}</div>
{children}
</div>
{url && (
Expand All @@ -50,8 +50,8 @@ const SuccessMessage: React.FC<SuccessMessageProps> = ({
</Button>
)}
{isClosable && (
<a onClick={close} className='mt-n2 mr-n2 pl-2'>
<span className={`icon ${infoMessageClass} close-btn`}>
<a onClick={close} className='close-btn'>
<span className={`icon ${infoMessageClass}`}>
<IonIcon icon={closeIcon} />
</span>
</a>
Expand Down
Loading