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

LaunchDarkly Token Analyzer #3948

Merged
Merged
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36e64af
initial commit
kashifkhan0771 Feb 21, 2025
1c92298
initial commit
kashifkhan0771 Feb 24, 2025
f143fa8
initial commit
kashifkhan0771 Feb 25, 2025
b840782
inital commit
kashifkhan0771 Feb 26, 2025
aab2c0a
initial working structure for launchdarkly analyzer
kashifkhan0771 Feb 27, 2025
9806026
added more apis
kashifkhan0771 Mar 4, 2025
cc2ac00
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 4, 2025
8ff00ae
added test cases
kashifkhan0771 Mar 4, 2025
745868e
removed imposter print statement
kashifkhan0771 Mar 4, 2025
6d3b8fa
updated some code
kashifkhan0771 Mar 4, 2025
6902b03
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 5, 2025
746e4fb
removed id from printResources
kashifkhan0771 Mar 5, 2025
8452bdd
added nabeel suggestion and set analysis info
kashifkhan0771 Mar 5, 2025
d445b25
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 5, 2025
25356be
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 6, 2025
4235470
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 6, 2025
143947a
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 7, 2025
8a0561a
resolved ahrav comments
kashifkhan0771 Mar 7, 2025
deccdd9
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 7, 2025
8d6265f
Merge branch 'main' into feat/oss-95-launchdarkly-analyzer
kashifkhan0771 Mar 10, 2025
08cdc2f
resolved ahrav comments
kashifkhan0771 Mar 10, 2025
47cdd0c
implemented ahrav's suggestion 🔥
kashifkhan0771 Mar 10, 2025
1c61fce
resolved linter error
kashifkhan0771 Mar 10, 2025
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
Prev Previous commit
Next Next commit
added more apis
  • Loading branch information
kashifkhan0771 committed Mar 4, 2025
commit 980602639d3153200d4994d32b984413dbc55e78
4 changes: 4 additions & 0 deletions pkg/analyzer/analyzers/launchdarkly/models.go
Original file line number Diff line number Diff line change
@@ -14,6 +14,10 @@ var (
holdoutsKey = "Holdout"
membersKey = "Member"
destinationsKey = "Destination"
templatesKey = "Templates"
teamsKey = "Teams"
webhooksKey = "Webhooks"
featureFlagsKey = "Feature Flags"
)

type SecretInfo struct {
231 changes: 229 additions & 2 deletions pkg/analyzer/analyzers/launchdarkly/requests.go
Original file line number Diff line number Diff line change
@@ -23,10 +23,14 @@ var (
repositoryKey: "/v2/code-refs/repositories",
projectKey: "/v2/projects",
environmentKey: "/v2/projects/%s/environments", // require project key
featureFlagsKey: "/v2/flags/%s", // require project key
experimentKey: "/v2/projects/%s/environments/%s/experiments", // require project key and env key
holdoutsKey: "/v2/projects/%s/environments/%s/holdouts", // require project key and env key
membersKey: "/v2/members",
destinationsKey: "/v2/destinations",
templatesKey: "/v2/templates",
teamsKey: "/v2/teams",
webhooksKey: "/v2/webhooks",
}
)

@@ -62,6 +66,15 @@ type projectsResponse struct {
} `json:"items"`
}

// featureFlagsResponse is the response of /v2/flags/<project_id> API
type featureFlagsResponse struct {
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"items"`
}

// environmentsResponse is the response of /v2/projects/<proj_key>/environments API
type environmentsResponse struct {
Items []struct {
@@ -92,6 +105,7 @@ type membersResponse struct {
} `json:"items"`
}

// holdoutsResponse is the response of /v2/projects/<project_id>/environments/<env_id>/holdouts API
type holdoutsResponse struct {
Items []struct {
ID string `json:"_id"`
@@ -101,6 +115,7 @@ type holdoutsResponse struct {
} `json:"items"`
}

// destinationsResponse is the response of /v2/destinations API
type destinationsResponse struct {
Items []struct {
ID string `json:"_id"`
@@ -110,6 +125,41 @@ type destinationsResponse struct {
} `json:"items"`
}

// templatesResponse is the response of /v2/templates API
type templatesResponse struct {
Items []struct {
ID string `json:"_id"`
Key string `json:"_key"`
Name string `json:"name"`
} `json:"items"`
}

// teamsResponse is the response of /v2/teams API
type teamsResponse struct {
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
Roles struct {
TotalCount int `json:"totalCount"`
} `json:"roles"`
Members struct {
TotalCount int `json:"totalCount"`
} `json:"members"`
Projects struct {
TotalCount int `json:"totalCount"`
} `json:"projects"`
} `json:"items"`
}

// webhooksResponse is the response of /v2/webhooks API
type webhooksResponse struct {
Items []struct {
ID string `json:"_id"`
Name string `json:"name"`
Url string `json:"url"`
} `json:"items"`
}

// makeLaunchDarklyRequest send the HTTP GET API request to passed url with passed token and return response body and status code
func makeLaunchDarklyRequest(client *http.Client, endpoint, token string) ([]byte, int, error) {
// create request
@@ -143,7 +193,7 @@ func makeLaunchDarklyRequest(client *http.Client, endpoint, token string) ([]byt
func CaptureResources(client *http.Client, token string, secretInfo *SecretInfo) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: It looks like the current approach has a potential deadlock issue.

Using an unbuffered error channel means that if there’s no active consumer, any goroutine trying to send an error will block indefinitely. In this case, the consumer for errChan only starts after wg.Wait(), meaning a goroutine could be stuck waiting to send, preventing wg.Wait() from ever completing.

Possible Solutions:

  1. Buffer the error channel – This would allow it to accommodate all potential errors. However, since the number of projects is unknown when this function is called, we don’t know how many errors to expect. We could use an arbitrarily large buffer size, but that comes with extra memory overhead.
  2. Consume errors as they occur – Instead of trying to predict the number of errors, we could read from the channel continuously and build aggregatedErrs dynamically. I’d personally lean toward this approach, but I’ll leave it up to you.

Ex:

func CaptureResources(client *http.Client, token string, secretInfo *SecretInfo) error {
	errChan := make(chan error, 1)

	// Aggregator goroutine: collects errors as they come.
	var aggregatedErrs []error
	var errAggWg sync.WaitGroup
	errAggWg.Add(1)
	go func() {
		defer errAggWg.Done()
		for err := range errChan {
			aggregatedErrs = append(aggregatedErrs, err)
		}
	}()

	var wg sync.WaitGroup
	// Helper to launch tasks concurrently.
	launch := func(task func() error) {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := task(); err != nil {
				errChan <- err
			}
		}()
	}

	// Launch top-level tasks.
	launch(func() error { return captureApplications(client, token, secretInfo) })
	launch(func() error { return captureRepositories(client, token, secretInfo) })
	// Capture projects.
	launch(func() error {
		if err := captureProjects(client, token, secretInfo); err != nil {
			return err
		}
		projects := secretInfo.listResourceByType(projectKey)
		for _, proj := range projects {
			launch(func() error { return captureProjectFeatureFlags(client, token, proj, secretInfo) })
			launch(func() error { return captureProjectEnv(client, token, proj, secretInfo) })
		}
		return nil
	})
	launch(func() error { return captureMembers(client, token, secretInfo) })
	launch(func() error { return captureDestinations(client, token, secretInfo) })
	launch(func() error { return captureTemplates(client, token, secretInfo) })
	launch(func() error { return captureTeams(client, token, secretInfo) })
	launch(func() error { return captureWebhooks(client, token, secretInfo) })

	wg.Wait()
	close(errChan)
	errAggWg.Wait()

	if len(aggregatedErrs) > 0 {
		return errors.Join(aggregatedErrs...)
	}
	return nil
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done ✅

var (
wg sync.WaitGroup
errChan = make(chan error, 10)
errChan = make(chan error)
aggregatedErrs = make([]string, 0)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: If we use a []error, we can avoid repeatedly converting between string → error → string → error.

optional: Since aggregatedErrs isn’t used in any of the goroutines, moving its declaration right before it's built makes the code a bit easier to read—no need to jump back and forth.

Ex:

var aggregatedErrs []error
for err := range errChan {
    aggregatedErrs = append(aggregatedErrs, err)
}

if len(aggregatedErrs) > 0 {
    return errors.Join(aggregatedErrs...)
}

)

@@ -173,9 +223,17 @@ func CaptureResources(client *http.Client, token string, secretInfo *SecretInfo)
errChan <- err
}

// for each project capture it's environments
// for each project capture it's flags, environments and other sub resources
projects := secretInfo.listResourceByType(projectKey)
for _, project := range projects {
wg.Add(1)
go func() {
defer wg.Done()
if err := captureProjectFeatureFlags(client, token, project, secretInfo); err != nil {
errChan <- err
}
}()

wg.Add(1)
go func() {
defer wg.Done()
@@ -204,6 +262,33 @@ func CaptureResources(client *http.Client, token string, secretInfo *SecretInfo)
}
}()

wg.Add(1)
go func() {
defer wg.Done()

if err := captureTemplates(client, token, secretInfo); err != nil {
errChan <- err
}
}()

wg.Add(1)
go func() {
defer wg.Done()

if err := captureTeams(client, token, secretInfo); err != nil {
errChan <- err
}
}()

wg.Add(1)
go func() {
defer wg.Done()

if err := captureWebhooks(client, token, secretInfo); err != nil {
errChan <- err
}
}()

wg.Wait()
close(errChan)

@@ -327,6 +412,45 @@ func captureProjects(client *http.Client, token string, secretInfo *SecretInfo)
}
}

// docs: https://launchdarkly.com/docs/api/feature-flags/get-feature-flags
func captureProjectFeatureFlags(client *http.Client, token string, parent Resource, secretInfo *SecretInfo) error {
projectKey, exist := parent.MetaData[MetadataKey]
if !exist {
return errors.New("project key not found")
}

response, statusCode, err := makeLaunchDarklyRequest(client, fmt.Sprintf(endpoints[featureFlagsKey], projectKey), token)
if err != nil {
return err
}

switch statusCode {
case http.StatusOK:
var flags = featureFlagsResponse{}

if err := json.Unmarshal(response, &flags); err != nil {
return err
}

for _, flag := range flags.Items {
secretInfo.appendResource(Resource{
ID: fmt.Sprintf("launchdarkly/proj/%s/flag/%s", projectKey, flag.Key),
Name: flag.Name,
Type: featureFlagsKey,
MetaData: map[string]string{
"Kind": flag.Kind,
},
})
}

return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}

// docs: https://launchdarkly.com/docs/api/environments/get-environments-by-project
func captureProjectEnv(client *http.Client, token string, parent Resource, secretInfo *SecretInfo) error {
projectKey, exist := parent.MetaData[MetadataKey]
@@ -541,3 +665,106 @@ func captureDestinations(client *http.Client, token string, secretInfo *SecretIn
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}

// docs: https://launchdarkly.com/docs/api/workflow-templates/get-workflow-templates
func captureTemplates(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[templatesKey], token)
if err != nil {
return err
}

switch statusCode {
case http.StatusOK:
var templates = templatesResponse{}

if err := json.Unmarshal(response, &templates); err != nil {
return err
}

for _, template := range templates.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/templates/%s", template.ID),
Name: template.Name,
Type: templatesKey,
}

secretInfo.appendResource(resource)
}

return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}

// docs: https://launchdarkly.com/docs/api/teams/get-teams
func captureTeams(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[teamsKey], token)
if err != nil {
return err
}

switch statusCode {
case http.StatusOK:
var teams = teamsResponse{}

if err := json.Unmarshal(response, &teams); err != nil {
return err
}

for _, team := range teams.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/teams/%s", team.Key),
Name: team.Name,
Type: teamsKey,
}

resource.updateResourceMetadata("Total Roles Count", fmt.Sprintf("%d", team.Roles.TotalCount))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: strconv.Itoa for these as well.

resource.updateResourceMetadata("Total Members Count", fmt.Sprintf("%d", team.Members.TotalCount))
resource.updateResourceMetadata("Total Projects Count", fmt.Sprintf("%d", team.Projects.TotalCount))

secretInfo.appendResource(resource)
}

return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}

// docs: https://launchdarkly.com/docs/api/webhooks/get-all-webhooks
func captureWebhooks(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[webhooksKey], token)
if err != nil {
return err
}

switch statusCode {
case http.StatusOK:
var webhooks = webhooksResponse{}

if err := json.Unmarshal(response, &webhooks); err != nil {
return err
}

for _, webhook := range webhooks.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/webhooks/%s", webhook.ID),
Name: webhook.Name,
Type: webhooksKey,
}

secretInfo.appendResource(resource)
}

return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}