From 2a50e925a976f3526de02ba7e3707c951d66dd79 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 21 Jul 2026 16:39:07 -0400 Subject: [PATCH 1/2] chore(setup): add setup flag verifier Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/setup/verifier.go | 83 +++++++++++++++++++++++++++++++++ internal/setup/verifier_test.go | 43 +++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 internal/setup/verifier.go create mode 100644 internal/setup/verifier_test.go diff --git a/internal/setup/verifier.go b/internal/setup/verifier.go new file mode 100644 index 00000000..7b265dbf --- /dev/null +++ b/internal/setup/verifier.go @@ -0,0 +1,83 @@ +package setup + +import ( + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +// VerifyResult describes the outcome of verifying SDK connectivity. +type VerifyResult struct { + Active bool `json:"active"` + Attempts int `json:"attempts"` + Elapsed string `json:"elapsed"` +} + +// Verifier polls the sdk-active endpoint until the SDK reports as active or a timeout is reached. +type Verifier struct { + Client resources.Client + Interval time.Duration + Timeout time.Duration +} + +// DefaultVerifier returns a Verifier with sensible defaults. +func DefaultVerifier(client resources.Client) *Verifier { + return &Verifier{ + Client: client, + Interval: 5 * time.Second, + Timeout: 120 * time.Second, + } +} + +// Verify polls GET /api/v2/projects/{project}/environments/{env}/sdk-active until active=true. +func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*VerifyResult, error) { + start := time.Now() + deadline := start.Add(v.Timeout) + attempts := 0 + + for { + attempts++ + active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey) + if err != nil { + return nil, err + } + if active { + return &VerifyResult{ + Active: true, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + }, nil + } + + if time.Now().After(deadline) { + return &VerifyResult{ + Active: false, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + }, nil + } + + time.Sleep(v.Interval) + } +} + +func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey string) (bool, error) { + path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active") + + res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", nil, nil, false) + if err != nil { + return false, fmt.Errorf("checking sdk-active: %w", err) + } + + var resp struct { + Active bool `json:"active"` + } + if err := json.Unmarshal(res, &resp); err != nil { + return false, fmt.Errorf("parsing sdk-active response: %w", err) + } + + return resp.Active, nil +} diff --git a/internal/setup/verifier_test.go b/internal/setup/verifier_test.go new file mode 100644 index 00000000..8ce339af --- /dev/null +++ b/internal/setup/verifier_test.go @@ -0,0 +1,43 @@ +package setup + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +func TestVerify_Active(t *testing.T) { + client := &resources.MockClient{ + Response: []byte(`{"active": true}`), + } + verifier := &Verifier{ + Client: client, + Interval: 10 * time.Millisecond, + Timeout: 1 * time.Second, + } + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env") + require.NoError(t, err) + assert.True(t, result.Active) + assert.Equal(t, 1, result.Attempts) +} + +func TestVerify_InactiveTimesOut(t *testing.T) { + client := &resources.MockClient{ + Response: []byte(`{"active": false}`), + } + verifier := &Verifier{ + Client: client, + Interval: 10 * time.Millisecond, + Timeout: 50 * time.Millisecond, + } + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env") + require.NoError(t, err) + assert.False(t, result.Active) + assert.Greater(t, result.Attempts, 1) +} From 0532976e7c54d4172c84e6a3c028bc18877944a9 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Mon, 3 Aug 2026 14:51:12 -0400 Subject: [PATCH 2/2] fix(setup): narrow the verify check to the configured SDK sdk-active answers for any SDK that initialized in the environment in the past seven days, so verification passed on unrelated traffic and a project already using LaunchDarkly reported success without the new SDK ever connecting. Filter on sdk_name, mapping the setup SDK id to the name the SDK reports itself as. An unknown id sends no filter rather than one that can never match, and VerifyResult records which name was used so an unnarrowed check cannot present itself as narrowed. Co-Authored-By: Claude Opus 5 (1M context) --- internal/setup/verifier.go | 41 ++++++++++++++++++++++---- internal/setup/verifier_test.go | 51 +++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/internal/setup/verifier.go b/internal/setup/verifier.go index 7b265dbf..25e1c290 100644 --- a/internal/setup/verifier.go +++ b/internal/setup/verifier.go @@ -14,6 +14,26 @@ type VerifyResult struct { Active bool `json:"active"` Attempts int `json:"attempts"` Elapsed string `json:"elapsed"` + // SDKName is the sdk_name the check was filtered on, empty when the SDK id had + // no known reported name. Empty means Active reports any SDK in the + // environment, not necessarily the one setup just configured. + SDKName string `json:"sdk_name,omitempty"` +} + +// reportedSDKNames maps a setup SDK id to the sdk_name that SDK identifies itself +// as in the events the sdk-active endpoint aggregates. Only the SDKs that reach +// verification need an entry: verify runs after init injected runnable code, which +// only happens for the append-safe SDKs. +var reportedSDKNames = map[string]string{ + "node-server": "node-server-sdk", + "python-server-sdk": "python-server-sdk", + "ruby-server-sdk": "ruby-server-sdk", +} + +// ReportedSDKName returns the sdk_name to filter sdk-active on for sdkID, or an +// empty string when it is unknown and the check cannot be narrowed. +func ReportedSDKName(sdkID string) string { + return reportedSDKNames[sdkID] } // Verifier polls the sdk-active endpoint until the SDK reports as active or a timeout is reached. @@ -32,15 +52,19 @@ func DefaultVerifier(client resources.Client) *Verifier { } } -// Verify polls GET /api/v2/projects/{project}/environments/{env}/sdk-active until active=true. -func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*VerifyResult, error) { +// Verify polls GET /api/v2/projects/{project}/environments/{env}/sdk-active until +// active=true, narrowed to the SDK sdkID reports itself as. Without the filter the +// endpoint answers for any SDK active in the environment in the past seven days, +// which reports success for a project that was already using LaunchDarkly. +func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey, sdkID string) (*VerifyResult, error) { start := time.Now() deadline := start.Add(v.Timeout) attempts := 0 + sdkName := ReportedSDKName(sdkID) for { attempts++ - active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey) + active, err := v.checkOnce(accessToken, baseURI, projectKey, envKey, sdkName) if err != nil { return nil, err } @@ -49,6 +73,7 @@ func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*Ver Active: true, Attempts: attempts, Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, }, nil } @@ -57,6 +82,7 @@ func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*Ver Active: false, Attempts: attempts, Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, }, nil } @@ -64,10 +90,15 @@ func (v *Verifier) Verify(accessToken, baseURI, projectKey, envKey string) (*Ver } } -func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey string) (bool, error) { +func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey, sdkName string) (bool, error) { path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active") - res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", nil, nil, false) + var query url.Values + if sdkName != "" { + query = url.Values{"sdk_name": []string{sdkName}} + } + + res, err := v.Client.MakeRequest(accessToken, "GET", path, "application/json", query, nil, false) if err != nil { return false, fmt.Errorf("checking sdk-active: %w", err) } diff --git a/internal/setup/verifier_test.go b/internal/setup/verifier_test.go index 8ce339af..d820971f 100644 --- a/internal/setup/verifier_test.go +++ b/internal/setup/verifier_test.go @@ -20,7 +20,7 @@ func TestVerify_Active(t *testing.T) { Timeout: 1 * time.Second, } - result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env") + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") require.NoError(t, err) assert.True(t, result.Active) assert.Equal(t, 1, result.Attempts) @@ -36,8 +36,55 @@ func TestVerify_InactiveTimesOut(t *testing.T) { Timeout: 50 * time.Millisecond, } - result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env") + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") require.NoError(t, err) assert.False(t, result.Active) assert.Greater(t, result.Attempts, 1) } + +// Unfiltered, sdk-active answers for any SDK active in the environment in the past +// seven days, so it reports success for a project that already used LaunchDarkly. +func TestVerify_FiltersOnTheConfiguredSDK(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "ruby-server-sdk") + + require.NoError(t, err) + assert.Equal(t, "ruby-server-sdk", client.Query.Get("sdk_name")) + assert.Equal(t, "ruby-server-sdk", result.SDKName) +} + +// The setup id and the name the SDK reports itself as are not always the same. +func TestVerify_UsesTheReportedSDKName(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + _, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "node-server") + + require.NoError(t, err) + assert.Equal(t, "node-server-sdk", client.Query.Get("sdk_name")) +} + +// An id with no known reported name must not send a filter that can never match. +func TestVerify_UnknownSDK_SendsNoFilter(t *testing.T) { + client := &resources.MockClient{Response: []byte(`{"active": true}`)} + verifier := &Verifier{Client: client, Interval: time.Millisecond, Timeout: time.Second} + + result, err := verifier.Verify("token", "https://app.launchdarkly.com", "proj", "env", "made-up-sdk") + + require.NoError(t, err) + assert.Empty(t, client.Query.Get("sdk_name")) + assert.Empty(t, result.SDKName, "an unnarrowed check must not claim it was narrowed") +} + +// Every SDK that init writes runnable code for reaches verification, so each needs +// a reported name or its check silently falls back to the whole environment. +func TestReportedSDKName_CoversEverySDKThatVerifies(t *testing.T) { + for _, sdk := range KnownSDKs { + if !InjectsInPlace(sdk.ID) { + continue + } + assert.NotEmpty(t, ReportedSDKName(sdk.ID), "%s reaches verify with no sdk_name", sdk.ID) + } +}