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
16 changes: 16 additions & 0 deletions internal/sync/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ 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 @@ -487,6 +488,7 @@ 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 @@ -508,6 +510,7 @@ 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 @@ -519,6 +522,19 @@ func TestClientPatchesSyncManifest(t *testing.T) {
}`, string(request.Body))
}

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")

require.ErrorContains(t, err, "unsupported formatVersion 99")
}

func TestIsConflictRecognizesAPIStatus(t *testing.T) {
assert.True(t, IsConflict(errors.New(`{"code":"conflict","statusCode":409}`)))
assert.False(t, IsConflict(errors.New(`{"code":"invalid_request","statusCode":400}`)))
Expand Down
47 changes: 40 additions & 7 deletions internal/sync/api/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ 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.
type SyncManifest struct {
Source string `json:"source"`
Items []SyncManifestResource `json:"items"`
FormatVersion int `json:"formatVersion"`
Source string `json:"source"`
Items []SyncManifestResource `json:"items"`
}

// SyncManifestResource is one versioned baseline entry.
Expand All @@ -22,6 +26,9 @@ type SyncManifestResource struct {
ResourceLookupKey string `json:"resourceLookupKey"`
Fingerprint string `json:"fingerprint"`
Version int `json:"version"`
FingerprintSchema string `json:"fingerprintSchema,omitempty"`
ReferenceDigest string `json:"referenceDigest,omitempty"`
AdapterBehavior string `json:"adapterBehavior,omitempty"`
}

// SyncManifestUpsert creates an entry, or updates the entry at Version.
Expand All @@ -30,6 +37,9 @@ type SyncManifestUpsert struct {
ResourceLookupKey string `json:"resourceLookupKey"`
Fingerprint string `json:"fingerprint"`
Version int `json:"version"`
FingerprintSchema string `json:"fingerprintSchema,omitempty"`
ReferenceDigest string `json:"referenceDigest,omitempty"`
AdapterBehavior string `json:"adapterBehavior,omitempty"`
}

// SyncManifestDeletion removes the entry at Version.
Expand All @@ -48,7 +58,14 @@ func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, e
if err != nil {
return SyncManifest{}, err
}
return decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey))
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
}

// PatchSyncManifest applies versioned changes and returns the new baseline.
Expand All @@ -68,10 +85,16 @@ func (client Client) PatchSyncManifest(
request := mutation{
method: http.MethodPatch, action: "update", resource: "sync manifest for project", key: projectKey,
body: struct {
Source string `json:"source"`
Upserts []SyncManifestUpsert `json:"upserts"`
Deletions []SyncManifestDeletion `json:"deletions"`
}{Source: source, Upserts: upserts, Deletions: deletions},
FormatVersion int `json:"formatVersion"`
Source string `json:"source"`
Upserts []SyncManifestUpsert `json:"upserts"`
Deletions []SyncManifestDeletion `json:"deletions"`
}{
FormatVersion: SyncManifestFormatVersion,
Source: source,
Upserts: upserts,
Deletions: deletions,
},
}
response, err := client.mutate(request, manifestPath(projectKey)...)
if err != nil {
Expand All @@ -84,9 +107,19 @@ 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
}

func validateSyncManifest(manifest SyncManifest) error {
if manifest.FormatVersion != SyncManifestFormatVersion {
return fmt.Errorf("unsupported formatVersion %d", manifest.FormatVersion)
}
return nil
}

func manifestPath(projectKey string) []string {
return projectPath(projectKey, "configs/sync/manifests")
}
9 changes: 9 additions & 0 deletions internal/sync/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ func (skill Skill) CreateName() string {
type Attachment struct {
Kind AttachmentKind
Version int
// Projection records the fields that a linked definition owns. It lets a
// dependency keep unowned LaunchDarkly fields when sync publishes it.
Projection Projection `json:"-" yaml:"-"`
// Upsert lets sync create the tool or the skill in LaunchDarkly when it
// is absent.
Upsert bool
Expand Down Expand Up @@ -91,6 +94,11 @@ func (attachment Attachment) ID(projectKey string) ResourceID {
type SyncedAttachment struct {
ProjectKey string
Attachment Attachment
// Source is the descriptor-backed repository source, or nil for a managed file.
Source *Reference
Dependencies []string
IndexedSelections []IndexedSelection
Projection Projection
}

// ID returns the identity of the tool or the skill.
Expand All @@ -103,6 +111,7 @@ func (attachment SyncedAttachment) ID() ResourceID {
// the same content.
func CanonicalAttachment(attachment Attachment) Attachment {
attachment.Version = 0
attachment.Projection = nil
attachment.Upsert = false
if attachment.Tool != nil {
tool := *attachment.Tool
Expand Down
106 changes: 106 additions & 0 deletions internal/sync/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package sync

import (
"errors"
"fmt"
)

// ErrorCode identifies one stable sync failure.
type ErrorCode string

const (
ErrorInvalidPathSyntax ErrorCode = "invalid_path_syntax"
ErrorPathNotFound ErrorCode = "path_not_found"
ErrorPathAmbiguous ErrorCode = "path_ambiguous"
ErrorPathTypeMismatch ErrorCode = "path_type_mismatch"
ErrorFilterTypeMismatch ErrorCode = "filter_value_type_mismatch"
ErrorMapTypeMismatch ErrorCode = "map_value_type_mismatch"
ErrorIndexOutOfRange ErrorCode = "index_out_of_range"
ErrorIndexedSelectionChanged ErrorCode = "indexed_selection_changed"
ErrorUnsupportedFormat ErrorCode = "unsupported_source_format"
ErrorFieldReadOnly ErrorCode = "field_read_only"
ErrorDuplicateKey ErrorCode = "duplicate_key"
ErrorInvalidSource ErrorCode = "invalid_source"
ErrorInvalidReference ErrorCode = "invalid_reference"
ErrorDefinitionConflict ErrorCode = "resource_definition_conflict"
ErrorLinkedSourceMissing ErrorCode = "linked_source_missing"
ErrorAttachmentDefinitionMissing ErrorCode = "attachment_definition_missing"
ErrorSyncSettingUnavailable ErrorCode = "sync_setting_unavailable"
ErrorBindingStale ErrorCode = "binding_stale"
ErrorStateChanged ErrorCode = "state_changed"
ErrorSourceNotWritable ErrorCode = "source_not_writable"
ErrorOverlappingEdits ErrorCode = "overlapping_edits"
ErrorResourceDeleted ErrorCode = "resource_deleted_in_launchdarkly"
ErrorPlaceholderAmbiguous ErrorCode = "placeholder_translation_ambiguous"
ErrorImportUnresolved ErrorCode = "import_unresolved"
)

// Problem describes one actionable sync error.
type Problem struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Resource string `json:"resource,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
Cause error `json:"-"`
}

// Error returns the message without embedding the stable code.
func (problem *Problem) Error() string {
if problem == nil {
return ""
}
if problem.Suggestion == "" {
return problem.Message
}
return problem.Message + ". " + problem.Suggestion
}

// Unwrap returns the lower-level cause.
func (problem *Problem) Unwrap() error {
if problem == nil {
return nil
}
return problem.Cause
}

// NewProblem creates one sync problem.
func NewProblem(code ErrorCode, message, suggestion string) *Problem {
return &Problem{Code: code, Message: message, Suggestion: suggestion}
}

// ProblemFrom returns the first sync problem in an error chain.
func ProblemFrom(err error) (*Problem, bool) {
var problem *Problem
ok := errors.As(err, &problem)
return problem, ok
}

// WithResource adds a resource identity to a problem.
func (problem *Problem) WithResource(id ResourceID) *Problem {
if problem != nil {
problem.Resource = id.Label()
}
return problem
}

// WithFile adds a source location to a problem.
func (problem *Problem) WithFile(file string, line, column int) *Problem {
if problem != nil {
problem.File, problem.Line, problem.Column = file, line, column
}
return problem
}

// WrapProblem adds a lower-level cause.
func WrapProblem(problem *Problem, cause error) *Problem {
if problem != nil {
problem.Cause = cause
if problem.Message == "" && cause != nil {
problem.Message = fmt.Sprint(cause)
}
}
return problem
}
43 changes: 37 additions & 6 deletions internal/sync/manifest/baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,41 @@ resources:
func TestDecodeLockRejectsInvalidContent(t *testing.T) {
for name, content := range map[string]string{
"unknown field": "formatVersion: 1\nresources: []\nextra: true\n",
"unknown version": "formatVersion: 2\nresources: []\n",
"unknown version": "formatVersion: 3\nresources: []\n",
"bad fingerprint": "formatVersion: 1\nresources:\n - kind: tool\n project: p\n key: k\n fingerprint: x\n",
} {
_, err := decodeLock([]byte(content))
assert.Error(t, err, name)
}
}

func TestLockFormatOneContainsSourceMetadata(t *testing.T) {
content, err := encodeLock(Manifest{Resources: []Resource{{
ResourceKind: syncdomain.KindSkill,
ProjectKey: "production",
LookupKey: "refunds",
Fingerprint: fingerprint("a"),
FingerprintSchema: syncdomain.ProjectionFingerprintSchema,
ReferenceDigest: fingerprint("b"),
AdapterBehavior: "markdown/1",
IndexedSelections: []syncdomain.IndexedSelection{{
Path: "select#0", CollectionLength: 2, Fingerprint: fingerprint("c"),
}},
}}})

require.NoError(t, err)
assert.Contains(t, string(content), "formatVersion: 1")
assert.Contains(t, string(content), "fingerprintSchema: launchdarkly.config.projection/1")
assert.Contains(t, string(content), "indexedSelections:")
assert.Contains(t, string(content), "collectionLength: 2")
assert.NotContains(t, string(content), "file:")
assert.NotContains(t, string(content), "direction:")
decoded, err := decodeLock(content)
require.NoError(t, err)
require.Len(t, decoded.Resources[0].IndexedSelections, 1)
assert.Equal(t, 2, decoded.Resources[0].IndexedSelections[0].CollectionLength)
}

// Other branches add entries to the shared remote manifest. A working copy
// without a sync.lock file must not track them.
func TestBaselineStoreWithoutLockFileTracksNothing(t *testing.T) {
Expand Down Expand Up @@ -97,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{Source: testSource}},
"remote entry removed": {remote: syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: testSource}},
}

for name, test := range tests {
Expand All @@ -117,7 +144,8 @@ func TestBaselineStoreSaveSendsRemoteVersionsAndWritesLockWithoutThem(t *testing
client := &manifestClient{
manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("b"), 5)},
patchResponses: []syncapi.SyncManifest{{
Source: testSource,
FormatVersion: syncapi.SyncManifestFormatVersion,
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 @@ -181,8 +209,11 @@ func TestBaselineStoreSaveDoesNotWriteLockWhenRemoteSaveFails(t *testing.T) {

func TestBaselineStoreSaveRemovesEmptyLock(t *testing.T) {
client := &manifestClient{
manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("a"), 4)},
patchResponses: []syncapi.SyncManifest{{Source: testSource}},
manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("a"), 4)},
patchResponses: []syncapi.SyncManifest{{
FormatVersion: syncapi.SyncManifestFormatVersion,
Source: testSource,
}},
}
lock := lockWith(t, fingerprint("a"), 4)
store := NewBaselineStore(NewStore(client, testSource), lock)
Expand Down Expand Up @@ -246,7 +277,7 @@ func variationID() syncdomain.ResourceID {
}

func remoteManifest(fingerprint string, version int) syncapi.SyncManifest {
return syncapi.SyncManifest{Source: testSource, Items: []syncapi.SyncManifestResource{{
return syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: testSource, Items: []syncapi.SyncManifestResource{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "support/default", Fingerprint: fingerprint, Version: version,
}}}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/sync/manifest/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
// The sync.lock file stores the baseline of one working copy. Engineers commit
// it with the .launchdarkly files, so that the baseline moves with the files.
const (
lockFormatVersion = 1
lockFormatVersion = FormatVersion
lockHeader = "# Written by ldcli sync. Commit this file with the .launchdarkly files.\n"
)

Expand Down
Loading
Loading