Skip to content
Merged
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
38 changes: 31 additions & 7 deletions internal/api/status_reps.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,41 @@ type StatusRep struct {
ClientVersion string `json:"clientVersion"`
}

// KeyStatus is the JSON representation of one accepted SDK or mobile key in the status endpoint's
// sdkKeys[] / mobileKeys[] arrays.
//
// Key is the non-secret human-readable identifier from the wire format (the "key" field of a
// sdkKeys/mobileKeys entry); it is omitted when the source carried no identifier (manual config, or
// an old-format payload predating concurrent keys). Value is the obscured credential secret (via
// sdks.ObscureKey). Expiry carries the Unix-millisecond expiry timestamp when the key is being phased
// out; it is omitted for permanent keys.
type KeyStatus struct {
Key string `json:"key,omitempty"`
Value string `json:"value"`
Expiry *int64 `json:"expiry,omitempty"`
}

// EnvironmentStatusRep is the per-environment JSON representation returned by the status endpoint.
//
// This is exported for use in integration test code.
type EnvironmentStatusRep struct {
SDKKey string `json:"sdkKey"`
EnvID string `json:"envId,omitempty"`
EnvKey string `json:"envKey,omitempty"`
EnvName string `json:"envName,omitempty"`
ProjKey string `json:"projKey,omitempty"`
ProjName string `json:"projName,omitempty"`
MobileKey string `json:"mobileKey,omitempty"`
// SDKKey is the obscured anchor SDK key — the key relay uses for its upstream connection. It
// designates which SDKKeys entry is the anchor.
SDKKey string `json:"sdkKey"`
// SDKKeys carries the full accepted set of server-side SDK keys — including the anchor — with their
// identifiers, obscured values, and optional expiry. Always present; always contains at least the
// anchor.
SDKKeys []KeyStatus `json:"sdkKeys"`
EnvID string `json:"envId,omitempty"`
EnvKey string `json:"envKey,omitempty"`
EnvName string `json:"envName,omitempty"`
ProjKey string `json:"projKey,omitempty"`
ProjName string `json:"projName,omitempty"`
// MobileKey is the obscured primary mobile key. It designates which MobileKeys entry is the primary.
MobileKey string `json:"mobileKey,omitempty"`
// MobileKeys carries the full accepted set of mobile keys — including the primary. Always present;
// empty for an environment with no mobile keys (e.g. server-side only).
MobileKeys []KeyStatus `json:"mobileKeys"`
ExpiringSDKKey string `json:"expiringSdkKey,omitempty"`
Status string `json:"status"`
ConnectionStatus ConnectionStatusRep `json:"connectionStatus"`
Expand Down
73 changes: 63 additions & 10 deletions relay/endpoints_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package relay
import (
"encoding/json"
"net/http"
"slices"
"strings"
"time"

"github.com/launchdarkly/ld-relay/v8/config"
"github.com/launchdarkly/ld-relay/v8/internal/api"
"github.com/launchdarkly/ld-relay/v8/internal/credential"
"github.com/launchdarkly/ld-relay/v8/internal/relayenv"
"github.com/launchdarkly/ld-relay/v8/internal/sdks"

Expand Down Expand Up @@ -46,25 +49,54 @@ func statusHandler(relay *Relay) http.Handler {
ProjName: identifiers.ProjName,
}

// Use the anchor SDK key and primary mobile key specifically — GetCredentials() may return
// multiple SDK and mobile keys (primary + expiring), so iterating it for these singular
// status fields would give a non-deterministic result.
if key := clientCtx.GetAnchorKey(); key.Defined() {
status.SDKKey = sdks.ObscureKey(string(key))
// One consistent snapshot of the accepted credential set drives every credential field
// below — the scalar anchor/primary designations, the full sdkKeys[]/mobileKeys[] arrays,
// and expiringSdkKey — so they cannot drift relative to each other under a concurrent
// reconcile.
accepted := clientCtx.GetAcceptedKeys()

// Scalar fields: the anchor SDK key and primary mobile key designate which array entry is
// the anchor / primary.
if accepted.Anchor.Defined() {
status.SDKKey = sdks.ObscureKey(string(accepted.Anchor))
}
if key := clientCtx.GetMobileKey(); key.Defined() {
status.MobileKey = sdks.ObscureKey(string(key))
if accepted.PrimaryMobile.Defined() {
status.MobileKey = sdks.ObscureKey(string(accepted.PrimaryMobile))
}
for _, c := range clientCtx.GetCredentials() {
if envID, ok := c.(config.EnvironmentID); ok {
status.EnvID = string(envID)
}
}
for _, c := range clientCtx.GetDeprecatedCredentials() {
if key, ok := c.(config.SDKKey); ok {
status.ExpiringSDKKey = sdks.ObscureKey(string(key))

// sdkKeys[] / mobileKeys[]: the full accepted set, grouped by kind. Always present (never
// null): a server-only env has an empty mobileKeys. Order is unspecified.
status.SDKKeys = make([]api.KeyStatus, 0, len(accepted.Server))
var expiringCandidates []expiringSDKKey
for value, info := range accepted.Server {
status.SDKKeys = append(status.SDKKeys, keyStatus(string(value), info))
// expiringSdkKey considers non-anchor server keys that carry an expiry.
if value != accepted.Anchor && info.Expiry != nil {
expiringCandidates = append(expiringCandidates, expiringSDKKey{value: string(value), expiry: *info.Expiry})
}
}
status.MobileKeys = make([]api.KeyStatus, 0, len(accepted.Mobile))
for value, info := range accepted.Mobile {
status.MobileKeys = append(status.MobileKeys, keyStatus(string(value), info))
}

// expiringSdkKey: the soonest-expiring non-anchor SDK key. Comparing by expiry then by value
// gives a total order, so the chosen key is deterministic even when several keys share the
// same expiry (map iteration order, and hence MinFunc's pick on a tie, is otherwise unstable).
if len(expiringCandidates) > 0 {
earliest := slices.MinFunc(expiringCandidates, func(a, b expiringSDKKey) int {
if c := a.expiry.Compare(b.expiry); c != 0 {
return c
}
return strings.Compare(a.value, b.value)
})
status.ExpiringSDKKey = sdks.ObscureKey(earliest.value)
}

client := clientCtx.GetClient()
if client == nil {
Expand Down Expand Up @@ -155,3 +187,24 @@ func statusHandler(relay *Relay) http.Handler {
_, _ = w.Write(data)
})
}

// expiringSDKKey is a candidate for the status endpoint's expiringSdkKey field: a non-anchor SDK key
// that carries an expiry. value is the plain credential; expiry is its (non-nil) expiry.
type expiringSDKKey struct {
value string
expiry time.Time
}

// keyStatus converts an accepted key — its credential value plus metadata — into its status-endpoint
// JSON representation, obscuring the secret value and surfacing the optional identifier and expiry.
func keyStatus(value string, k credential.AcceptedKey) api.KeyStatus {
ks := api.KeyStatus{Value: sdks.ObscureKey(value)}
if k.Key != nil {
ks.Key = *k.Key
}
if k.Expiry != nil {
ms := k.Expiry.UnixMilli()
ks.Expiry = &ms
}
return ks
}
145 changes: 145 additions & 0 deletions relay/endpoints_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"testing"
"time"

"github.com/launchdarkly/ld-relay/v8/internal/credential"
"github.com/launchdarkly/ld-relay/v8/internal/sdkauth"

c "github.com/launchdarkly/ld-relay/v8/config"
"github.com/launchdarkly/ld-relay/v8/internal/sdks"
st "github.com/launchdarkly/ld-relay/v8/internal/sharedtest"
"github.com/launchdarkly/ld-relay/v8/internal/sharedtest/testclient"
"github.com/launchdarkly/ld-relay/v8/internal/util"

ct "github.com/launchdarkly/go-configtypes"
"github.com/launchdarkly/go-sdk-common/v3/ldtime"
Expand Down Expand Up @@ -56,6 +58,36 @@ func TestEndpointsStatus(t *testing.T) {
st.AssertJSONPathMatch(t, p.relay.version, status, "version")
st.AssertJSONPathMatch(t, ld.Version, status, "clientVersion")
})

t.Run("sdkKeys/mobileKeys arrays carry the full accepted set including the anchor", func(t *testing.T) {
var config c.Config
config.Environment = st.MakeEnvConfigs(st.EnvMain, st.EnvMobile)

withStartedRelay(t, config, func(p relayTestParams) {
r, _ := http.NewRequest("GET", "http://localhost/status", nil)
result, body := st.DoRequest(r, p.relay)
assert.Equal(t, http.StatusOK, result.StatusCode)
status := ldvalue.Parse(body)

// EnvMain is a manually-configured single-key env: sdkKeys[] is present and contains
// exactly the anchor (full set includes it); manual config carries no key identifier.
sdkKeys := status.GetByKey("environments").GetByKey(st.EnvMain.Name).GetByKey("sdkKeys")
require.Equal(t, 1, sdkKeys.Count(), "sdkKeys must contain the anchor")
assert.Equal(t, sdks.ObscureKey(string(st.EnvMain.Config.SDKKey)),
sdkKeys.GetByIndex(0).GetByKey("value").StringValue())

// EnvMain has no mobile key — mobileKeys is present but empty.
mobileKeys := status.GetByKey("environments").GetByKey(st.EnvMain.Name).GetByKey("mobileKeys")
assert.Equal(t, 0, mobileKeys.Count())
assert.Equal(t, ldvalue.ArrayType, mobileKeys.Type(), "mobileKeys present (not null) even when empty")

// EnvMobile has both: its mobile key appears in mobileKeys[].
mobMobileKeys := status.GetByKey("environments").GetByKey(st.EnvMobile.Name).GetByKey("mobileKeys")
require.Equal(t, 1, mobMobileKeys.Count())
assert.Equal(t, sdks.ObscureKey(string(st.EnvMobile.Config.MobileKey)),
mobMobileKeys.GetByIndex(0).GetByKey("value").StringValue())
})
})
})

t.Run("connection interruption - less than DisconnectedStatusTime", func(t *testing.T) {
Expand Down Expand Up @@ -131,3 +163,116 @@ func TestEndpointsStatus(t *testing.T) {
})
})
}

// findKeyStatusByValue returns the sdkKeys[]/mobileKeys[] entry whose obscured "value" matches, or a
// null value. Array entry order is unspecified, so callers look entries up by value.
func findKeyStatusByValue(arr ldvalue.Value, obscuredValue string) ldvalue.Value {
for i := 0; i < arr.Count(); i++ {
if arr.GetByIndex(i).GetByKey("value").StringValue() == obscuredValue {
return arr.GetByIndex(i)
}
}
return ldvalue.Null()
}

// TestEndpointsStatusExpiringSDKKey drives a multi-key environment through the real /status handler:
// it reconciles an env to an anchor plus two non-anchor expiring SDK keys and asserts the
// expiringSdkKey selection, the per-key expiry/identifier serialization in sdkKeys[], and that the
// soonest-expiry pick is deterministic on an exact expiry tie.
func TestEndpointsStatusExpiringSDKKey(t *testing.T) {
getStatus := func(t *testing.T, p relayTestParams, set credential.AcceptedSet) ldvalue.Value {
env, err := p.relay.getEnvironment(sdkauth.New(st.EnvMain.Config.SDKKey))
require.NoError(t, err)
require.NotNil(t, env)
env.ReconcileCredentials(set)

r, _ := http.NewRequest("GET", "http://localhost/status", nil)
result, body := st.DoRequest(r, p.relay)
require.Equal(t, http.StatusOK, result.StatusCode)
return ldvalue.Parse(body).GetByKey("environments").GetByKey(st.EnvMain.Name)
}

t.Run("soonest-expiring non-anchor key, with expiry and identifier surfaced", func(t *testing.T) {
var config c.Config
config.Environment = st.MakeEnvConfigs(st.EnvMain)
withStartedRelay(t, config, func(p relayTestParams) {
anchor := st.EnvMain.Config.SDKKey
soon := time.Now().Add(1 * time.Hour)
later := time.Now().Add(2 * time.Hour)
set, err := credential.NewAcceptedSetBuilder().
WithAnchor(credential.SDKKeyParams{Value: anchor}).
WithSDKKey(credential.SDKKeyParams{Value: "sdk-soon", Key: util.PtrOrNil("soon-key"), Expiry: util.PtrOrNil(soon)}).
WithSDKKey(credential.SDKKeyParams{Value: "sdk-later", Expiry: util.PtrOrNil(later)}).
Build()
require.NoError(t, err)

envStatus := getStatus(t, p, set)

// expiringSdkKey is the obscured soonest-expiring non-anchor key.
st.AssertJSONPathMatch(t, sdks.ObscureKey("sdk-soon"), envStatus, "expiringSdkKey")

// sdkKeys[] carries the full set (anchor + both non-anchor keys).
sdkKeys := envStatus.GetByKey("sdkKeys")
require.Equal(t, 3, sdkKeys.Count())

// The expiring key surfaces its identifier and Unix-millis expiry.
soonEntry := findKeyStatusByValue(sdkKeys, sdks.ObscureKey("sdk-soon"))
require.False(t, soonEntry.IsNull())
assert.Equal(t, "soon-key", soonEntry.GetByKey("key").StringValue())
assert.Equal(t, float64(soon.UnixMilli()), soonEntry.GetByKey("expiry").Float64Value())

// A key with no identifier omits "key" entirely (omitempty, nil pointer).
laterEntry := findKeyStatusByValue(sdkKeys, sdks.ObscureKey("sdk-later"))
require.False(t, laterEntry.IsNull())
assert.True(t, laterEntry.GetByKey("key").IsNull(), `"key" must be omitted when the source carried no identifier`)
})
})

t.Run("tie on expiry resolves deterministically to the smaller value", func(t *testing.T) {
var config c.Config
config.Environment = st.MakeEnvConfigs(st.EnvMain)
withStartedRelay(t, config, func(p relayTestParams) {
anchor := st.EnvMain.Config.SDKKey
sameExpiry := time.Now().Add(1 * time.Hour)
set, err := credential.NewAcceptedSetBuilder().
WithAnchor(credential.SDKKeyParams{Value: anchor}).
WithSDKKey(credential.SDKKeyParams{Value: "sdk-bbb", Expiry: util.PtrOrNil(sameExpiry)}).
WithSDKKey(credential.SDKKeyParams{Value: "sdk-aaa", Expiry: util.PtrOrNil(sameExpiry)}).
Build()
require.NoError(t, err)

envStatus := getStatus(t, p, set)
// With equal expiries, the smaller value (sdk-aaa) wins deterministically.
st.AssertJSONPathMatch(t, sdks.ObscureKey("sdk-aaa"), envStatus, "expiringSdkKey")
})
})
}

// TestKeyStatus verifies the helper that converts an accepted key into its status-endpoint JSON form.
func TestKeyStatus(t *testing.T) {
strptr := func(s string) *string { return &s }

t.Run("permanent key with identifier", func(t *testing.T) {
ks := keyStatus("sdk-abc123", credential.AcceptedKey{Key: strptr("default")})
assert.Equal(t, "default", ks.Key)
assert.Equal(t, sdks.ObscureKey("sdk-abc123"), ks.Value)
assert.Nil(t, ks.Expiry)
})

t.Run("nil identifier yields empty Key (omitted in JSON)", func(t *testing.T) {
ks := keyStatus("sdk-legacy", credential.AcceptedKey{Key: nil})
assert.Equal(t, "", ks.Key)
})

t.Run("expiring key has expiry in Unix milliseconds", func(t *testing.T) {
expiry := time.Date(2099, 6, 1, 12, 0, 0, 0, time.UTC)
ks := keyStatus("sdk-old", credential.AcceptedKey{Key: strptr("old-key"), Expiry: &expiry})
require.NotNil(t, ks.Expiry)
assert.Equal(t, expiry.UnixMilli(), *ks.Expiry)
})

t.Run("mobile key value is obscured", func(t *testing.T) {
ks := keyStatus("mob-secret", credential.AcceptedKey{Key: strptr("mob-1")})
assert.Equal(t, sdks.ObscureKey("mob-secret"), ks.Value)
})
}
Loading