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: Launchdarkly importer #2530

Merged
merged 36 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1307acc
feat(launch-darkly-importer): Add initial strucutre for launch darkly…
ajhelsby Jul 26, 2023
e83bd02
feat(launch-darkly-importer): Add serializer for ld importer
ajhelsby Jul 27, 2023
f5fdabd
feat(launch-darkly-importer): Start mapping ld fields to fs
ajhelsby Jul 27, 2023
9e16879
feat(launch-darkly-importer): Add initial strucutre for launch darkly…
ajhelsby Jul 26, 2023
eb5a6f3
feat(launch-darkly-importer): Move launch darkly methods to their own…
ajhelsby Aug 7, 2023
2a1647e
feat(launch-darkly-importer): Start adding logging for ld imports
ajhelsby Aug 7, 2023
8da37a7
feat(launch-darkly-importer): Add data for testing purposes
ajhelsby Aug 7, 2023
6ebade9
feat(launch-darkly-importer): Add more logs for creating environments…
ajhelsby Aug 7, 2023
c10f93f
feat(launch-darkly-importer): Reformat files
ajhelsby Aug 7, 2023
0b9ce94
feat(launch-darkly-importer): Add error logs
ajhelsby Aug 7, 2023
413549d
feat(launch-darkly-importer): Add db migrations
ajhelsby Aug 8, 2023
94e9aff
feat(launch-darkly-importer): Add unit test coverage
ajhelsby Aug 8, 2023
9cf4c6b
feat(launch-darkly-importer): Update unit tests
ajhelsby Aug 10, 2023
cfa138a
feat(launch-darkly-importer): Fix unit tests
ajhelsby Sep 1, 2023
a58c63f
feature/2348/launch darkly importer
dabeeeenster Aug 17, 2023
fdf6154
started LD docs
dabeeeenster Aug 18, 2023
44d6384
feature/2348/launch darkly importer
dabeeeenster Sep 4, 2023
b455a64
feature/2348/launch darkly importer
dabeeeenster Sep 5, 2023
4a5f26f
fix: bring back flagsmith integration
khvn26 Sep 19, 2023
efd4acc
feat: add paginated, typed LaunchDarkly client
khvn26 Sep 27, 2023
af9e6bf
feat: add LaunchDarkly import request service and background task
khvn26 Sep 28, 2023
c8bde88
fix: better error handling
khvn26 Sep 28, 2023
e7a4e1f
feat: add imports/launch-darkly endpoint
khvn26 Sep 28, 2023
ab43363
fix: working typing for 3.10
khvn26 Sep 28, 2023
5ca8941
fix: test correctness
khvn26 Sep 28, 2023
51d0828
fix: use user is as salt, restore isort config
khvn26 Oct 2, 2023
06797a0
fix: use public LD swagger url
khvn26 Oct 2, 2023
8c2582d
fix: missing import
khvn26 Oct 2, 2023
be0147a
fix: fix test
khvn26 Oct 2, 2023
d9438e2
fix: get rid of the max age for obfuscated LD values
khvn26 Oct 3, 2023
56d9bf9
fix: user_id annotation
khvn26 Oct 3, 2023
fb20fe0
feat: prevent more than one active import request
khvn26 Oct 3, 2023
f99de8e
fix: merge import data with existing environments
khvn26 Oct 6, 2023
d94f63c
feat: add default tag, prevent duplicate tags
khvn26 Oct 9, 2023
5ecfb56
feat: update docs
khvn26 Oct 9, 2023
ef03dbb
fix: ignore tag order
khvn26 Oct 9, 2023
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
14 changes: 14 additions & 0 deletions api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,17 @@ django-collect-static:
.PHONY: serve
serve:
poetry run gunicorn --bind 0.0.0.0:8000 app.wsgi --reload

.PHONY: generate-ld-client-types
generate-ld-client-types:
curl -sSL https://app.launchdarkly.com/api/v2/openapi.json | \
npx openapi-format /dev/fd/0 \
--filterFile ld-openapi-filter.yaml | \
datamodel-codegen \
--output integrations/launch_darkly/types.py \
--output-model-type typing.TypedDict \
--target-python-version 3.10 \
--use-double-quotes \
--use-standard-collections \
--wrap-string-literal \
--special-field-name-prefix=
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"integrations.webhook",
"integrations.dynatrace",
"integrations.flagsmith",
"integrations.launch_darkly",
# Rate limiting admin endpoints
"axes",
"telemetry",
Expand Down
1 change: 1 addition & 0 deletions api/audit/related_object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ class RelatedObjectType(enum.Enum):
ENVIRONMENT = "Environment"
CHANGE_REQUEST = "Change request"
EDGE_IDENTITY = "Edge Identity"
IMPORT_REQUEST = "Import request"
Empty file.
6 changes: 6 additions & 0 deletions api/integrations/launch_darkly/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from django.apps import AppConfig


class LaunchDarklyConfigurationConfig(AppConfig):
name = "integrations.launch_darkly"
108 changes: 108 additions & 0 deletions api/integrations/launch_darkly/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from typing import Any, Iterator, Optional

from requests import Session

from integrations.launch_darkly import types as ld_types
from integrations.launch_darkly.constants import (
LAUNCH_DARKLY_API_BASE_URL,
LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE,
LAUNCH_DARKLY_API_VERSION,
)


class LaunchDarklyClient:
def __init__(self, token: str) -> None:
client_session = Session()
client_session.headers.update(
{
"Authorization": token,
"LD-API-Version": LAUNCH_DARKLY_API_VERSION,
}
)
self.client_session = client_session

def _get_json_response(
self,
endpoint: str,
params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
full_url = f"{LAUNCH_DARKLY_API_BASE_URL}{endpoint}"
response = self.client_session.get(full_url, params=params)
response.raise_for_status()
return response.json()

def _iter_paginated_items(
self,
collection_endpoint: str,
additional_params: Optional[dict[str, str]] = None,
) -> Iterator[dict[str, Any]]:
params = {"limit": LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE}
if additional_params:
params.update(additional_params)

response_json = self._get_json_response(
endpoint=collection_endpoint,
params=params,
)
while True:
yield from response_json.get("items") or []
links: Optional[dict[str, ld_types.Link]] = response_json.get("_links")
if (
links
and (next_link := links.get("next"))
and (next_endpoint := next_link.get("href"))
):
# Don't specify params here because links.next.href includes the
# original limit and calculates offsets accordingly.
response_json = self._get_json_response(
endpoint=next_endpoint,
)
else:
return

def get_project(self, project_key: str) -> ld_types.Project:
"""operationId: getProject"""
endpoint = f"/api/v2/projects/{project_key}"
return self._get_json_response(
endpoint=endpoint, params={"expand": "environments"}
)

def get_environments(self, project_key: str) -> list[ld_types.Environment]:
"""operationId: getEnvironmentsByProject"""
endpoint = f"/api/v2/projects/{project_key}/environments"
return list(
self._iter_paginated_items(
collection_endpoint=endpoint,
)
)

def get_flags(self, project_key: str) -> list[ld_types.FeatureFlag]:
"""operationId: getFeatureFlags"""
endpoint = f"/api/v2/flags/{project_key}"
return list(
self._iter_paginated_items(
collection_endpoint=endpoint,
)
)

def get_flag_count(self, project_key: str) -> int:
"""operationId: getFeatureFlags

Request minimal info and return the total flag count.
"""
endpoint = f"/api/v2/flags/{project_key}"
flags: ld_types.FeatureFlags = self._get_json_response(
endpoint=endpoint,
params={"limit": 1},
)
return flags["totalCount"]

def get_flag_tags(self) -> list[str]:
"""operationId: getTags"""
endpoint = "/api/v2/tags"
return list(
self._iter_paginated_items(
collection_endpoint=endpoint,
additional_params={"kind": "flag"},
)
)
7 changes: 7 additions & 0 deletions api/integrations/launch_darkly/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
LAUNCH_DARKLY_API_BASE_URL = "https://app.launchdarkly.com"
LAUNCH_DARKLY_API_VERSION = "20220603"
# Maximum limit for /api/v2/projects/
# /api/v2/flags/ seemingly not limited, but let's not get too greedy
LAUNCH_DARKLY_API_ITEM_COUNT_LIMIT_PER_PAGE = 1000

LAUNCH_DARKLY_IMPORTED_TAG_COLOR = "#3d4db6"
129 changes: 129 additions & 0 deletions api/integrations/launch_darkly/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Generated by Django 3.2.20 on 2023-09-17 14:34

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import simple_history.models


class Migration(migrations.Migration):
initial = True

dependencies = [
("api_keys", "0003_masterapikey_is_admin"),
("projects", "0019_add_limits"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="LaunchDarklyImportRequest",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
("ld_project_key", models.CharField(max_length=2000)),
("ld_token", models.CharField(max_length=2000)),
("status", models.JSONField()),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="projects.project",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="HistoricalLaunchDarklyImportRequest",
fields=[
(
"id",
models.IntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("completed_at", models.DateTimeField(blank=True, null=True)),
("ld_project_key", models.CharField(max_length=2000)),
("ld_token", models.CharField(max_length=2000)),
("status", models.JSONField()),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField()),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"created_by",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"master_api_key",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="api_keys.masterapikey",
),
),
(
"project",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="projects.project",
),
),
],
options={
"verbose_name": "historical launch darkly import request",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": "history_date",
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.20 on 2023-10-03 17:30

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("launch_darkly", "0001_initial"),
]

operations = [
migrations.AddConstraint(
model_name="launchdarklyimportrequest",
constraint=models.UniqueConstraint(
condition=models.Q(("status__result__isnull", True)),
fields=("project", "ld_project_key"),
name="unique_project_ld_project_key_status_result_null",
),
),
]
Empty file.
64 changes: 64 additions & 0 deletions api/integrations/launch_darkly/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from typing import TYPE_CHECKING, Literal, Optional, TypedDict

from core.models import abstract_base_auditable_model_factory
from django.db import models
from typing_extensions import NotRequired

from audit.related_object_type import RelatedObjectType
from projects.models import Project

if TYPE_CHECKING: # pragma: no cover
from users.models import FFAdminUser


class LaunchDarklyImportStatus(TypedDict):
requested_environment_count: int
requested_flag_count: int
result: NotRequired[Literal["success", "failure"]]
error_message: NotRequired[str]


class LaunchDarklyImportRequest(
abstract_base_auditable_model_factory(),
):
history_record_class_path = "features.models.HistoricalLaunchDarklyImportRequest"
related_object_type = RelatedObjectType.IMPORT_REQUEST

created_by = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
completed_at = models.DateTimeField(null=True, blank=True)

ld_project_key = models.CharField(max_length=2000)
ld_token = models.CharField(max_length=2000)

status: LaunchDarklyImportStatus = models.JSONField()

def get_create_log_message(self, _) -> str:
return "New LaunchDarkly import requested"

def get_update_log_message(self, _) -> Optional[str]:
if not self.completed_at:
return None
if self.status.get("result") == "success":
return "LaunchDarkly import completed successfully"
if error_message := self.status.get("error_message"):
return f"LaunchDarkly import failed with error: {error_message}"
return "LaunchDarkly import failed"

def get_audit_log_author(self) -> "FFAdminUser":
return self.created_by

def _get_project(self) -> Project:
return self.project

class Meta:
constraints = [
models.UniqueConstraint(
name="unique_project_ld_project_key_status_result_null",
fields=["project", "ld_project_key"],
condition=models.Q(status__result__isnull=True),
)
]
Loading