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
42 changes: 13 additions & 29 deletions internal/sync/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,6 @@ func TestClientArchiveVariationReturnsTransportError(t *testing.T) {

func TestClientGetsSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"formatVersion": 1,
"source": "git:example.com/acme/repo",
"items": [{
"resourceKind": "variation",
Expand All @@ -494,7 +493,6 @@ func TestClientGetsSyncManifest(t *testing.T) {

func TestClientPatchesSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"formatVersion": 1,
"source": "git:example.com/acme/repo",
"items": []
}`)}}
Expand All @@ -516,7 +514,6 @@ func TestClientPatchesSyncManifest(t *testing.T) {
assert.Equal(t, http.MethodPatch, request.Method)
assert.Equal(t, "application/json", request.ContentType)
assert.JSONEq(t, `{
"formatVersion": 1,
"source": "git:example.com/acme/repo",
"upserts": [{
"resourceKind": "variation",
Expand All @@ -528,36 +525,23 @@ func TestClientPatchesSyncManifest(t *testing.T) {
}`, string(request.Body))
}

// LaunchDarkly answers a project and a source that no sync wrote yet with an
// empty manifest that has no formatVersion. That is an empty manifest, not
// an unsupported one.
func TestClientAcceptsAnUnwrittenSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{"items":[],"source":"git:example.com/acme/repo"}`)}}
// The manifest API has no format version. An empty manifest and a manifest
// with entries both read as they are.
func TestClientReadsAManifestWithoutAFormatVersion(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{
[]byte(`{"items":[],"source":"git:example.com/acme/repo"}`),
[]byte(`{"items":[{"resourceKind":"variation","resourceLookupKey":"support/helpful","fingerprint":"sha256:abc","version":1}],"source":"git:example.com/acme/repo"}`),
}}
client := NewClient(transport, "token", "https://example.com")

manifest, err := client.GetSyncManifest("project", "git:example.com/acme/repo")

empty, err := client.GetSyncManifest("project", "git:example.com/acme/repo")
require.NoError(t, err)
assert.Empty(t, manifest.Items)
assert.True(t, manifest.Unwritten())

transport = &recordingClient{Responses: [][]byte{[]byte(`{"items":[{"resourceKind":"variation","resourceLookupKey":"support/helpful","fingerprint":"sha256:abc","version":1}],"source":"git:example.com/acme/repo"}`)}}
client = NewClient(transport, "token", "https://example.com")
_, err = client.GetSyncManifest("project", "git:example.com/acme/repo")
require.ErrorContains(t, err, "unsupported formatVersion 0", "entries without a format version are not an unwritten manifest")
}

func TestClientRejectsUnknownSyncManifestVersion(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"formatVersion": 99,
"source": "git:example.com/acme/repo",
"items": []
}`)}}
client := NewClient(transport, "token", "https://example.com")

_, err := client.GetSyncManifest("project", "git:example.com/acme/repo")
assert.Empty(t, empty.Items)

require.ErrorContains(t, err, "unsupported formatVersion 99")
written, err := client.GetSyncManifest("project", "git:example.com/acme/repo")
require.NoError(t, err)
require.Len(t, written.Items, 1)
assert.Equal(t, 1, written.Items[0].Version)
}

func TestIsConflictRecognizesAPIStatus(t *testing.T) {
Expand Down
54 changes: 12 additions & 42 deletions internal/sync/api/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ import (
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

// SyncManifestFormatVersion is the remote sync manifest schema.
const SyncManifestFormatVersion = 1

// SyncManifest is the sync baseline that LaunchDarkly stores for one project
// and one repository source.
// and one repository source. The API has no format version of its own: the
// path version of the API governs the shape, and the sync.lock file has its
// own formatVersion.
type SyncManifest struct {
FormatVersion int `json:"formatVersion"`
Source string `json:"source"`
Items []SyncManifestResource `json:"items"`
Source string `json:"source"`
Items []SyncManifestResource `json:"items"`
}

// SyncManifestResource is one versioned baseline entry.
Expand Down Expand Up @@ -58,14 +56,7 @@ func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, e
if err != nil {
return SyncManifest{}, err
}
manifest, err := decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey))
if err != nil {
return SyncManifest{}, err
}
if err := validateSyncManifest(manifest); err != nil {
return SyncManifest{}, fmt.Errorf("sync manifest for project %q: %w", projectKey, err)
}
return manifest, nil
return decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey))
}

// PatchSyncManifest applies versioned changes and returns the new baseline.
Expand All @@ -85,15 +76,13 @@ func (client Client) PatchSyncManifest(
request := mutation{
method: http.MethodPatch, action: "update", resource: "sync manifest for project", key: projectKey,
body: struct {
FormatVersion int `json:"formatVersion"`
Source string `json:"source"`
Upserts []SyncManifestUpsert `json:"upserts"`
Deletions []SyncManifestDeletion `json:"deletions"`
Source string `json:"source"`
Upserts []SyncManifestUpsert `json:"upserts"`
Deletions []SyncManifestDeletion `json:"deletions"`
}{
FormatVersion: SyncManifestFormatVersion,
Source: source,
Upserts: upserts,
Deletions: deletions,
Source: source,
Upserts: upserts,
Deletions: deletions,
},
}
response, err := client.mutate(request, manifestPath(projectKey)...)
Expand All @@ -107,28 +96,9 @@ func (client Client) PatchSyncManifest(
// caller must read the manifest again to find its state.
return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err)
}
if err := validateSyncManifest(manifest); err != nil {
return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err)
}
return manifest, nil
}

// Unwritten reports whether the manifest is the one that LaunchDarkly returns
// for a project and a source that no sync wrote yet: no entries and no format
// version. Sync treats it as an empty manifest of the supported format.
func (manifest SyncManifest) Unwritten() bool {
return manifest.FormatVersion == 0 && len(manifest.Items) == 0
}

// validateSyncManifest accepts a manifest of the supported format, and an
// unwritten manifest, which has no format to check.
func validateSyncManifest(manifest SyncManifest) error {
if manifest.Unwritten() || manifest.FormatVersion == SyncManifestFormatVersion {
return nil
}
return fmt.Errorf("unsupported formatVersion %d", manifest.FormatVersion)
}

func manifestPath(projectKey string) []string {
return projectPath(projectKey, "configs/sync/manifests")
}
10 changes: 4 additions & 6 deletions internal/sync/manifest/baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func TestBaselineStaleComparesFingerprints(t *testing.T) {
"newer version with a different fingerprint": {remote: remoteManifest(fingerprint("b"), 5), stale: true},
"newer version with the same fingerprint": {remote: remoteManifest(fingerprint("a"), 5)},
"same version with a different fingerprint": {remote: remoteManifest(fingerprint("b"), 4), stale: true},
"remote entry removed": {remote: syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: testSource}},
"remote entry removed": {remote: syncapi.SyncManifest{Source: testSource}},
}

for name, test := range tests {
Expand All @@ -144,8 +144,7 @@ func TestBaselineStoreSaveSendsRemoteVersionsAndWritesLockWithoutThem(t *testing
client := &manifestClient{
manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("b"), 5)},
patchResponses: []syncapi.SyncManifest{{
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: testSource,
Source: testSource,
Items: []syncapi.SyncManifestResource{
{ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "support/default", Fingerprint: fingerprint("c"), Version: 6},
{ResourceKind: syncdomain.KindTool, ResourceLookupKey: "search", Fingerprint: fingerprint("d"), Version: 1},
Expand Down Expand Up @@ -211,8 +210,7 @@ func TestBaselineStoreSaveRemovesEmptyLock(t *testing.T) {
client := &manifestClient{
manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("a"), 4)},
patchResponses: []syncapi.SyncManifest{{
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: testSource,
Source: testSource,
}},
}
lock := lockWith(t, fingerprint("a"), 4)
Expand Down Expand Up @@ -277,7 +275,7 @@ func variationID() syncdomain.ResourceID {
}

func remoteManifest(fingerprint string, version int) syncapi.SyncManifest {
return syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: testSource, Items: []syncapi.SyncManifestResource{{
return syncapi.SyncManifest{Source: testSource, Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "support/default", Fingerprint: fingerprint, Version: version,
}}}
}
Expand Down
9 changes: 0 additions & 9 deletions internal/sync/manifest/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,6 @@ func (store Store) patch(
}

func (store Store) resourcesFromRemote(projectKey string, remote syncapi.SyncManifest) ([]Resource, error) {
// A project and a source that no sync wrote yet have an empty manifest
// without a format version.
if !remote.Unwritten() && remote.FormatVersion != syncapi.SyncManifestFormatVersion {
return nil, fmt.Errorf(
"sync manifest for project %q has unsupported formatVersion %d",
projectKey,
remote.FormatVersion,
)
}
if remote.Source != "" && remote.Source != store.source {
return nil, fmt.Errorf(
"sync manifest for project %q returned source %q instead of %q", projectKey, remote.Source, store.source,
Expand Down
32 changes: 8 additions & 24 deletions internal/sync/manifest/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,13 @@ func (client *manifestClient) PatchSyncManifest(
return response, nil
}

// A project that no sync wrote yet has an empty manifest without a format
// version in LaunchDarkly. It loads as no entries, and a manifest with
// entries still needs the supported format.
func TestStoreLoadsAnUnwrittenManifestAsEmpty(t *testing.T) {
// A project that no sync wrote yet has a manifest with no entries, which
// loads as no entries beside the projects that have some.
func TestStoreLoadsAnEmptyManifest(t *testing.T) {
client := &manifestClient{manifests: map[string]syncapi.SyncManifest{
"fresh": {Source: "git:example/repo"},
"written": {
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: "git:example/repo",
Source: "git:example/repo",
Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/a",
Fingerprint: fingerprint("a"), Version: 2,
Expand All @@ -70,31 +68,19 @@ func TestStoreLoadsAnUnwrittenManifestAsEmpty(t *testing.T) {
require.NoError(t, err)
require.Len(t, loaded.Resources, 1)
assert.Equal(t, "written", loaded.Resources[0].ProjectKey)

client.manifests["fresh"] = syncapi.SyncManifest{
Source: "git:example/repo",
Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/b",
Fingerprint: fingerprint("b"), Version: 1,
}},
}
_, err = NewStore(client, "git:example/repo").Load([]string{"fresh"})
require.ErrorContains(t, err, "unsupported formatVersion 0")
}

func TestStoreLoadsProjectManifests(t *testing.T) {
client := &manifestClient{manifests: map[string]syncapi.SyncManifest{
"zeta": {
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: "git:example/repo",
Source: "git:example/repo",
Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/b",
Fingerprint: fingerprint("b"), Version: 4,
}},
},
"alpha": {
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: "git:example/repo",
Source: "git:example/repo",
Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/a",
Fingerprint: fingerprint("a"), Version: 2,
Expand All @@ -113,8 +99,7 @@ func TestStoreLoadsProjectManifests(t *testing.T) {

func TestStoreUpdatesWithRemoteVersions(t *testing.T) {
client := &manifestClient{patchResponses: []syncapi.SyncManifest{{
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: "git:example/repo",
Source: "git:example/repo",
Items: []syncapi.SyncManifestResource{
{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/existing",
Expand Down Expand Up @@ -178,8 +163,7 @@ func TestStoreReportsOptimisticConflict(t *testing.T) {

func TestStoreReadsBackAnUncertainPatchThatSucceeded(t *testing.T) {
applied := syncapi.SyncManifest{
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: "git:example/repo",
Source: "git:example/repo",
Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/variation",
Fingerprint: fingerprint("b"), Version: 2,
Expand Down
6 changes: 3 additions & 3 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2601,7 +2601,7 @@ func runScoped(t *testing.T, root string, api *directAPI, options syncprompt.Opt
func runWith(t *testing.T, root string, api *directAPI, runner syncprompt.Runner, options syncprompt.Options) (string, string, error) {
t.Helper()
if manifestsByRoot[root] == nil {
manifestsByRoot[root] = &syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}}
manifestsByRoot[root] = &syncapi.SyncManifest{Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}}
}
api.manifest = manifestsByRoot[root]
var output, notes bytes.Buffer
Expand Down Expand Up @@ -2914,7 +2914,7 @@ func runPrompt(t *testing.T, root string, client resources.Client, arguments ...
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
manifest := manifestsByRoot[root]
if manifest == nil {
manifest = &syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}}
manifest = &syncapi.SyncManifest{Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}}
manifestsByRoot[root] = manifest
}
switch api := client.(type) {
Expand Down Expand Up @@ -3077,7 +3077,7 @@ func writeRemoteManifest(t *testing.T, root string, values ...syncdomain.Variati
Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/" + value.Key,
}, fingerprint(t, value))
}
remote := &syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: "git:example/repo"}
remote := &syncapi.SyncManifest{Source: "git:example/repo"}
for _, resource := range manifest.Resources {
remote.Items = append(remote.Items, syncapi.SyncManifestResource{
ResourceKind: resource.ResourceKind,
Expand Down
Loading