diff --git a/internal/setup/verifier.go b/internal/setup/verifier.go new file mode 100644 index 00000000..25e1c290 --- /dev/null +++ b/internal/setup/verifier.go @@ -0,0 +1,114 @@ +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"` + // 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. +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, 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, sdkName) + if err != nil { + return nil, err + } + if active { + return &VerifyResult{ + Active: true, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, + }, nil + } + + if time.Now().After(deadline) { + return &VerifyResult{ + Active: false, + Attempts: attempts, + Elapsed: time.Since(start).Round(time.Millisecond).String(), + SDKName: sdkName, + }, nil + } + + time.Sleep(v.Interval) + } +} + +func (v *Verifier) checkOnce(accessToken, baseURI, projectKey, envKey, sdkName string) (bool, error) { + path, _ := url.JoinPath(baseURI, "api/v2/projects", projectKey, "environments", envKey, "sdk-active") + + 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) + } + + 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..d820971f --- /dev/null +++ b/internal/setup/verifier_test.go @@ -0,0 +1,90 @@ +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", "node-server") + 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", "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) + } +}