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
6 changes: 6 additions & 0 deletions internal/sync/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ type recordingClient struct {
Requests []recordedRequest
Responses [][]byte
Err error
// Errors fails one request by its index. A nil entry lets the request
// answer from Responses.
Errors []error
}

var _ resources.Client = &recordingClient{}
Expand All @@ -52,6 +55,9 @@ func (client *recordingClient) MakeRequest(
if client.Err != nil {
return nil, client.Err
}
if index := len(client.Requests) - 1; index < len(client.Errors) && client.Errors[index] != nil {
return nil, client.Errors[index]
}
if len(client.Responses) < len(client.Requests) {
return nil, nil
}
Expand Down
136 changes: 136 additions & 0 deletions internal/sync/api/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package api

import (
"fmt"
"maps"
"net/http"
"net/url"
"slices"
"strings"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

// syncSettingsPageLimit is the page size of the sync settings endpoint. It
// matches the most resources that one call accepts.
const syncSettingsPageLimit = 100

type resolvedSetting struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Direction syncdomain.SyncDirection `json:"direction"`
}

// SyncDirections returns the effective LaunchDarkly setting for each
// resource. A resource of a kind that LaunchDarkly has no setting for is left
// out, and the caller decides what a missing setting means.
func (client Client) SyncDirections(
projectKey string,
ids []syncdomain.ResourceID,
) (map[syncdomain.ResourceID]syncdomain.SyncDirection, error) {
if len(ids) == 0 {
return map[syncdomain.ResourceID]syncdomain.SyncDirection{}, nil
}
if len(ids) > 100 {
return nil, fmt.Errorf("read sync settings: at most 100 resources are allowed")
}
byKind := map[syncdomain.Kind][]syncdomain.ResourceID{}
for _, id := range ids {
if id.ProjectKey != projectKey {
return nil, fmt.Errorf("read sync settings: %s is not in project %q", id.Label(), projectKey)
}
byKind[id.Kind] = append(byKind[id.Kind], id)
}
result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(ids))
// The endpoint rejects a request that names a kind it has no setting for,
// and the rejection fails every resource of the request. Each kind travels
// alone, so a tool or a skill cannot take the variations down with it.
for _, kind := range slices.Sorted(maps.Keys(byKind)) {
directions, err := client.syncDirectionsOfKind(projectKey, byKind[kind])
if err != nil {
return nil, err
}
maps.Copy(result, directions)
}
return result, nil
}

// syncDirectionsOfKind reads the settings of resources of one kind. A kind
// that LaunchDarkly rejects as unsupported has no settings.
func (client Client) syncDirectionsOfKind(
projectKey string,
ids []syncdomain.ResourceID,
) (map[syncdomain.ResourceID]syncdomain.SyncDirection, error) {
query := url.Values{}
for _, id := range ids {
query.Add("resources", string(id.Kind)+":"+id.LookupKey)
}
result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(ids))
// The endpoint pages its items. Read every page, so that a batch of more
// than one page of resources does not look like missing settings.
for offset := 0; ; {
page, err := client.readSyncSettingsPage(projectKey, query, offset)
if unsupportedResourceKind(err) {
return result, nil
}
if err != nil {
return nil, err
}
for _, item := range page.Items {
id := syncdomain.ResourceID{
Kind: item.ResourceKind, ProjectKey: projectKey, LookupKey: item.ResourceLookupKey,
}
if !item.Direction.Valid() {
return nil, syncdomain.NewProblem(
syncdomain.ErrorSyncSettingUnavailable,
fmt.Sprintf("LaunchDarkly returned unsupported direction %q", item.Direction),
"Configure a supported sync setting in LaunchDarkly.",
).WithResource(id)
}
result[id] = item.Direction
}
offset += len(page.Items)
// The total count ends the paging when the response has one. Without
// it, a short page is the last one.
switch {
case len(page.Items) == 0:
return result, nil
case page.TotalCount > 0 && offset >= page.TotalCount:
return result, nil
case page.TotalCount == 0 && len(page.Items) < syncSettingsPageLimit:
return result, nil
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
}
}

// unsupportedResourceKind reports the 400 response of the sync settings
// endpoint for a resource kind that LaunchDarkly has no setting for.
func unsupportedResourceKind(err error) bool {
response, ok := responseError(err)
if !ok {
return false
}
status, _ := response["statusCode"].(float64)
message, _ := response["message"].(string)
return int(status) == http.StatusBadRequest && strings.Contains(strings.ToLower(message), "unsupported resource kind")
}

// readSyncSettingsPage reads one page of resolved settings. A response
// without a body is an empty page.
func (client Client) readSyncSettingsPage(projectKey string, query url.Values, offset int) (Page[resolvedSetting], error) {
page := Page[resolvedSetting]{}
values := pageQuery(syncSettingsPageLimit, offset)
for key, value := range query {
values[key] = value
}
response, err := client.read(
fmt.Sprintf("get sync settings in project %q", projectKey),
projectKey,
values,
projectPath(projectKey, "configs", "sync", "settings")...,
)
if err != nil || len(response) == 0 {
return page, err
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return decodeJSON[Page[resolvedSetting]](response, "sync settings response")
}
132 changes: 132 additions & 0 deletions internal/sync/api/settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package api

import (
"errors"
"fmt"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

// The endpoint rejects a request that names a kind it has no setting for,
// and the rejection fails every resource of the request, so each kind is
// asked for alone.
func TestSyncDirectionsAsksForEachKindAlone(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{
[]byte(`{"items": [{"resourceKind":"tool","resourceLookupKey":"search","direction":"server_canonical"}]}`),
[]byte(`{"items": [{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"code_canonical"}]}`),
}}
client := NewClient(transport, "token", "https://example.com")
variation := syncdomain.VariationID("project", "support", "default")
tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}

directions, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool})

require.NoError(t, err)
assert.Equal(t, syncdomain.SyncDirectionCodeCanonical, directions[variation])
assert.Equal(t, syncdomain.SyncDirectionServerCanonical, directions[tool])
require.Len(t, transport.Requests, 2, "one request per kind, in kind order")
for _, request := range transport.Requests {
assert.Equal(t, http.MethodGet, request.Method)
assert.Equal(t, "https://example.com/api/v2/projects/project/configs/sync/settings", request.Path)
}
assert.Equal(t, []string{"tool:search"}, transport.Requests[0].Query["resources"])
assert.Equal(t, []string{"variation:support/default"}, transport.Requests[1].Query["resources"])
}

// LaunchDarkly answers 400 "unsupported resource kind" for a kind that has no
// sync setting yet. The variations of the same call still get their settings,
// and the resources of the rejected kind have none.
func TestSyncDirectionsTreatsAnUnsupportedKindAsHavingNoSetting(t *testing.T) {
unsupported := errors.New(`{"code":"invalid_request","message":"unsupported resource kind","statusCode":400}`)
// The kinds are asked for in name order: skill, tool, variation.
transport := &recordingClient{
Errors: []error{unsupported, unsupported, nil},
Responses: [][]byte{nil, nil,
[]byte(`{"items": [{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"both"}]}`),
},
}
client := NewClient(transport, "token", "https://example.com")
variation := syncdomain.VariationID("project", "support", "default")
tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}
skill := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"}

directions, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool, skill})

require.NoError(t, err, "a kind without settings does not fail the variations")
assert.Equal(t, map[syncdomain.ResourceID]syncdomain.SyncDirection{variation: syncdomain.SyncDirectionBoth}, directions)
require.Len(t, transport.Requests, 3)
assert.Equal(t, []string{"variation:support/default"}, transport.Requests[2].Query["resources"])

other := errors.New(`{"code":"invalid_request","message":"resource key is not valid","statusCode":400}`)
transport = &recordingClient{Errors: []error{other}}
client = NewClient(transport, "token", "https://example.com")
_, err = client.SyncDirections("project", []syncdomain.ResourceID{tool})
require.Error(t, err, "another 400 still fails the call")
}

func TestSyncDirectionsReturnsOnlyResolvedResults(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{"items":[]}`)}}
client := NewClient(transport, "token", "https://example.com")
id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"}

directions, err := client.SyncDirections("project", []syncdomain.ResourceID{id})

require.NoError(t, err)
assert.Empty(t, directions)
}

func TestSyncDirectionsReadsEveryPage(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{
[]byte(`{"items": [
{"resourceKind":"tool","resourceLookupKey":"first","direction":"both"},
{"resourceKind":"tool","resourceLookupKey":"second","direction":"both"}
], "totalCount": 3}`),
[]byte(`{"items": [
{"resourceKind":"tool","resourceLookupKey":"third","direction":"code_canonical"}
], "totalCount": 3}`),
}}
client := NewClient(transport, "token", "https://example.com")
ids := make([]syncdomain.ResourceID, 0, 3)
for _, key := range []string{"first", "second", "third"} {
ids = append(ids, syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: key})
}

directions, err := client.SyncDirections("project", ids)

require.NoError(t, err)
assert.Len(t, directions, 3)
assert.Equal(t, syncdomain.SyncDirectionCodeCanonical, directions[ids[2]])
require.Len(t, transport.Requests, 2)
assert.Equal(t, []string{"0"}, transport.Requests[0].Query["offset"])
assert.Equal(t, []string{"2"}, transport.Requests[1].Query["offset"])
assert.Equal(t, []string{"100"}, transport.Requests[1].Query["limit"])
assert.Equal(t, []string{"tool:first", "tool:second", "tool:third"}, transport.Requests[1].Query["resources"])
}

func TestSyncDirectionsPagesWithoutATotalCount(t *testing.T) {
full := make([]string, 0, syncSettingsPageLimit)
ids := make([]syncdomain.ResourceID, 0, syncSettingsPageLimit)
for index := range syncSettingsPageLimit {
key := fmt.Sprintf("tool%d", index)
full = append(full, fmt.Sprintf(`{"resourceKind":"tool","resourceLookupKey":%q,"direction":"both"}`, key))
ids = append(ids, syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: key})
}
transport := &recordingClient{Responses: [][]byte{
[]byte(`{"items": [` + strings.Join(full, ",") + `]}`),
[]byte(`{"items": []}`),
}}
client := NewClient(transport, "token", "https://example.com")

directions, err := client.SyncDirections("project", ids)

require.NoError(t, err)
assert.Len(t, directions, syncSettingsPageLimit)
require.Len(t, transport.Requests, 2, "a full page without a total count is followed by a request for the next page")
assert.Equal(t, []string{"100"}, transport.Requests[1].Query["offset"])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When a single GET to the sync settings endpoint asks for both a variation and a tool, we return a '400 unsupported resource kind' and the whole call fails, variation included. The test below is currently failing.

Should we even be supporting both tools and skills here, or should the client only send variations? If this is intended behavior lmk and I can stamp!

Suggested change
}
}
func TestSyncDirectionsAsksOnlyForVariationSettings(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{"items": [
{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"both"}
]}`)}}
client := NewClient(transport, "token", "https://example.com")
variation := syncdomain.VariationID("project", "support", "default")
tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}
_, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool})
require.NoError(t, err)
require.Len(t, transport.Requests, 1)
for _, resource := range transport.Requests[0].Query["resources"] {
assert.True(t, strings.HasPrefix(resource, "variation:"), "gonfalon answers 400 unsupported resource kind to %q", resource)
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you, that was a real failure: one 400 for the tool took the variations down with it. SyncDirections now asks for each resource kind in a request of its own, so a variation request never carries a tool or a skill. A kind that LaunchDarkly answers with 400 unsupported resource kind has no settings, and any other 400 still fails the call. A linked tool or skill without a setting then gets the existing sync_setting_unavailable problem, which says that LaunchDarkly does not provide that setting yet and suggests detach, so the variations of a plan sync and the tool or skill is blocked rather than guessed. That keeps the client ready for the day the endpoint supports tools and skills, and the acceptance tests that fake those settings still hold. TestSyncDirectionsAsksForEachKindAlone and TestSyncDirectionsTreatsAnUnsupportedKindAsHavingNoSetting cover it. One open question for you: do you want a linked tool or skill to sync as both until LaunchDarkly has a setting for it, instead of the block? That is a one-line change in applySourceDirections and I will make it if you prefer it.

5 changes: 5 additions & 0 deletions internal/sync/manifest/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ func (manifest *Manifest) Sort() {
})
}

// Has reports whether the manifest records a resource.
func (manifest Manifest) Has(id syncdomain.ResourceID) bool {
return manifest.index(id) >= 0
}

func (manifest Manifest) index(id syncdomain.ResourceID) int {
return slices.IndexFunc(manifest.Resources, func(resource Resource) bool { return resource.ID() == id })
}
Loading
Loading