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
128 changes: 128 additions & 0 deletions internal/sync/manifest/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package manifest

import (
"fmt"
"regexp"
"slices"
"strings"

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

// FormatVersion is the current manifest schema version.
const FormatVersion = 1

var fingerprintPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)

// Manifest records the common resource state accepted by the last successful
// synchronization.
type Manifest struct {
FormatVersion int `yaml:"formatVersion"`
Resources []Resource `yaml:"resources"`
}

// Resource identifies one tracked resource and its last synchronized
// fingerprint.
type Resource struct {
ResourceKind syncdomain.Kind `yaml:"resourceKind"`
ProjectKey string `yaml:"projectKey"`
LookupKey string `yaml:"lookupKey"`
Fingerprint string `yaml:"fingerprint"`
}

// ID returns the common identity represented by this manifest entry.
func (resource Resource) ID() syncdomain.ResourceID {
return syncdomain.ResourceID{Kind: resource.ResourceKind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey}
}

// New returns an empty current-version manifest.
func New() Manifest {
return Manifest{FormatVersion: FormatVersion, Resources: []Resource{}}
}

// SetFingerprint records the last synchronized state for one resource.
func (manifest *Manifest) SetFingerprint(id syncdomain.ResourceID, fingerprint string) {
for index := range manifest.Resources {
if manifest.Resources[index].ID() == id {
manifest.Resources[index].Fingerprint = fingerprint
return
}
}
manifest.Resources = append(manifest.Resources, Resource{
ResourceKind: id.Kind,
ProjectKey: id.ProjectKey,
LookupKey: id.LookupKey,
Fingerprint: fingerprint,
})
}

// Remove deletes one resource from the manifest.
func (manifest *Manifest) Remove(id syncdomain.ResourceID) {
for index, resource := range manifest.Resources {
if resource.ID() == id {
manifest.Resources = append(manifest.Resources[:index], manifest.Resources[index+1:]...)
return
}
}
}

// Validate checks the manifest schema and resource identities.
func (manifest Manifest) Validate() error {
if manifest.FormatVersion != FormatVersion {
return fmt.Errorf("unsupported manifest formatVersion %d", manifest.FormatVersion)
}

seen := make(map[syncdomain.ResourceID]struct{}, len(manifest.Resources))
for _, resource := range manifest.Resources {
if err := validatePathSegment("resource kind", string(resource.ResourceKind)); err != nil {
return err
}
if err := validatePathSegment("project key", resource.ProjectKey); err != nil {
return err
}
if err := validateLookupKey(resource.LookupKey); err != nil {
return err
}
if !fingerprintPattern.MatchString(resource.Fingerprint) {
return fmt.Errorf("invalid fingerprint for %s/%s", resource.ProjectKey, resource.LookupKey)
}

identity := resource.ID()
if _, exists := seen[identity]; exists {
return fmt.Errorf("duplicate manifest resource %s/%s", resource.ProjectKey, resource.LookupKey)
}
seen[identity] = struct{}{}
}
return nil
}

// Sort orders resources deterministically for stable Git diffs.
func (manifest *Manifest) Sort() {
slices.SortFunc(manifest.Resources, func(left, right Resource) int {
if result := strings.Compare(string(left.ResourceKind), string(right.ResourceKind)); result != 0 {
return result
}
if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 {
return result
}
return strings.Compare(left.LookupKey, right.LookupKey)
})
}

// validateLookupKey checks every slash-delimited resource identity segment.
func validateLookupKey(value string) error {
for _, segment := range strings.Split(value, "/") {
if err := validatePathSegment("lookup segment", segment); err != nil {
return fmt.Errorf("invalid lookup key %q: %w", value, err)
}
}
return nil
}

// validatePathSegment rejects values that are empty, unsafe, or non-portable.
func validatePathSegment(name, value string) error {
if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\`) || strings.IndexByte(value, 0) >= 0 {
return fmt.Errorf("invalid %s %q", name, value)
}
return nil
}
27 changes: 27 additions & 0 deletions internal/sync/manifest/model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package manifest

import (
"testing"

"github.com/stretchr/testify/assert"

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

func TestManifestSetFingerprintAndRemove(t *testing.T) {
first := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/first"}
second := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/second"}
manifest := New()

manifest.SetFingerprint(first, "sha256:first")
manifest.SetFingerprint(first, "sha256:updated")
manifest.SetFingerprint(second, "sha256:second")
manifest.Remove(first)

assert.Equal(t, []Resource{{
ResourceKind: second.Kind,
ProjectKey: second.ProjectKey,
LookupKey: second.LookupKey,
Fingerprint: "sha256:second",
}}, manifest.Resources)
}
129 changes: 129 additions & 0 deletions internal/sync/manifest/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package manifest

import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
"gopkg.in/yaml.v3"
)

// FileName is the repository-local sync manifest filename.
const FileName = "manifest.yaml"

// Store reads and atomically writes the committed synchronization manifest.
type Store struct {
path string
}

// NewStore creates a manifest store rooted at the Git repository.
func NewStore(repositoryRoot string) Store {
return Store{path: filepath.Join(repositoryRoot, syncdomain.RootDir, FileName)}
}

// Load returns the manifest and whether it already exists.
func (store Store) Load() (Manifest, bool, error) {
data, err := os.ReadFile(store.path)
if errors.Is(err, os.ErrNotExist) {
return New(), false, nil
}
if err != nil {
return Manifest{}, false, fmt.Errorf("read sync manifest: %w", err)
}

decoder := yaml.NewDecoder(bytes.NewReader(data))
// A committed manifest is an API between CLI versions. Reject unknown
// fields instead of silently discarding data written by a newer schema.
decoder.KnownFields(true)
var manifest Manifest
if err := decoder.Decode(&manifest); err != nil {
return Manifest{}, false, fmt.Errorf("decode sync manifest: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
err = fmt.Errorf("multiple YAML documents are not supported")
}
return Manifest{}, false, fmt.Errorf("decode sync manifest: %w", err)
}
if err := manifest.Validate(); err != nil {
return Manifest{}, false, fmt.Errorf("validate sync manifest: %w", err)
}
manifest.Sort()
return manifest, true, nil
}

// Write atomically replaces the manifest with deterministic YAML.
func (store Store) Write(manifest Manifest) error {
manifest.FormatVersion = FormatVersion
if manifest.Resources == nil {
manifest.Resources = []Resource{}
}
manifest.Sort()
if err := manifest.Validate(); err != nil {
return fmt.Errorf("validate sync manifest: %w", err)
}

var data bytes.Buffer
encoder := yaml.NewEncoder(&data)
encoder.SetIndent(2)
if err := encoder.Encode(manifest); err != nil {
return fmt.Errorf("encode sync manifest: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("encode sync manifest: %w", err)
}

directory := filepath.Dir(store.path)
if err := os.MkdirAll(directory, 0o755); err != nil {
return fmt.Errorf("create sync directory: %w", err)
}
temporary, err := os.CreateTemp(directory, ".manifest-*.yaml")
if err != nil {
return fmt.Errorf("create temporary sync manifest: %w", err)
}
temporaryPath := temporary.Name()
defer func() { _ = os.Remove(temporaryPath) }()

// Flush and close the complete temporary file before the single rename
// commit point, so readers observe either the old or the new manifest.
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
return fmt.Errorf("set sync manifest permissions: %w", err)
}
if _, err := temporary.Write(data.Bytes()); err != nil {
_ = temporary.Close()
return fmt.Errorf("write sync manifest: %w", err)
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return fmt.Errorf("sync manifest contents: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close sync manifest: %w", err)
}
if err := os.Rename(temporaryPath, store.path); err != nil {
return fmt.Errorf("replace sync manifest: %w", err)
}

// The rename above is the commit point. Directory syncing improves crash
// durability where the platform supports it, but must not turn a committed
// replacement into a reported failure.
if directoryHandle, err := os.Open(directory); err == nil {
_ = directoryHandle.Sync()
_ = directoryHandle.Close()
}
Comment thread
ctawiah marked this conversation as resolved.
return nil
}

// Remove deletes the manifest when rolling back creation of a new manifest.
func (store Store) Remove() error {
if err := os.Remove(store.path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove sync manifest: %w", err)
}
return nil
}
Loading
Loading