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
115 changes: 115 additions & 0 deletions internal/sync/link/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Package link validates and writes repository source descriptors.
package link

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

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncadapter "github.com/launchdarkly/ldcli/internal/sync/adapter"
synclocal "github.com/launchdarkly/ldcli/internal/sync/local"
)

// Options are the explicit inputs for one repository source link.
type Options struct {
Store synclocal.Store
RepositoryRoot string
WorkingDirectory string
ID syncdomain.ResourceID
File string
Format string
Select string
Map map[string]string
}

// Prepared is one validated descriptor and its compiled local resource.
type Prepared struct {
ID syncdomain.ResourceID
Reference syncdomain.Reference
Definition synclocal.SourceDefinition
Path string
Content []byte
// Previous is the descriptor that Prepare read, or nil when none existed.
Previous []byte
// written reports that Write published Content, so that Rollback has a
// write of this process to undo.
written bool
}

// Prepare validates a repository source without writing its descriptor.
func Prepare(options Options) (Prepared, error) {
if err := options.Store.ValidateDescriptorTarget(options.ID); err != nil {
return Prepared{}, err
}
reference, err := synclocal.NewSourceReference(
options.RepositoryRoot,
options.WorkingDirectory,
options.File,
options.Format,
options.Select,
options.Map,
)
if err != nil {
return Prepared{}, err
}
path, err := synclocal.ReferencePath(options.ID)
if err != nil {
return Prepared{}, err
}
previous, readErr := os.ReadFile(filepath.Join(options.RepositoryRoot, filepath.FromSlash(path)))
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return Prepared{}, readErr
}
if previous != nil {
// The link options set the file, the format, the selection, and the
// map. A relink keeps the tools and skills of the current descriptor,
// so that it does not read as a local removal of them. The one read
// serves the snapshot and the lists, so Write checks the bytes that
// the lists came from.
current, err := synclocal.ParseDescriptor(previous, options.ID)
if err != nil {
return Prepared{}, fmt.Errorf("read the current descriptor %s: %w", path, err)
}
reference.Tools, reference.Skills = current.Tools, current.Skills
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
reference, err = syncadapter.NormalizeReference(options.ID, reference)
if err != nil {
return Prepared{}, err
}
definition, err := synclocal.CompileSource(options.RepositoryRoot, options.ID, reference)
if err != nil {
return Prepared{}, err
}
content, err := synclocal.RenderDescriptor(options.ID, reference)
if err != nil {
return Prepared{}, err
}
return Prepared{
ID: options.ID, Reference: reference, Definition: definition, Path: path, Content: content,
Previous: previous,
}, nil
}

// Write atomically publishes the prepared descriptor. The descriptor must
// still be as Prepare read it, present or absent, so that a write of another
// process in the review window is not lost.
func (prepared *Prepared) Write(store synclocal.Store) (string, error) {
path, err := store.WriteDescriptorFrom(prepared.ID, prepared.Reference, prepared.Previous)
if err == nil {
prepared.written = true
}
return path, err
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

// Rollback undoes a Write that succeeded: it restores the previous
// descriptor, or removes the new one. A descriptor that another process
// wrote stays, also when it has the same content, because a Write that was
// refused or did not run published nothing.
func (prepared *Prepared) Rollback(store synclocal.Store) error {
if !prepared.written {
return nil
}
return store.RestoreDescriptor(prepared.ID, prepared.Previous, prepared.Content)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
199 changes: 199 additions & 0 deletions internal/sync/link/link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package link

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"

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

func TestPrepareDoesNotWriteUntilExplicitWrite(t *testing.T) {
root := t.TempDir()
source := []byte(`from agents import Agent
support = Agent(instructions="Help with orders.")
`)
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644))
id := syncdomain.VariationID("production", "support", "default")

prepared, err := Prepare(Options{
Store: synclocal.NewStore(root),
RepositoryRoot: root,
WorkingDirectory: root,
ID: id,
File: "agents.py",
Format: "python",
Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"},
})

require.NoError(t, err)
require.Equal(t, ".launchdarkly/production/configs/support/default.ref.yaml", prepared.Path)
require.NoFileExists(t, filepath.Join(root, filepath.FromSlash(prepared.Path)))
path, err := prepared.Write(synclocal.NewStore(root))
require.NoError(t, err)
descriptor, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path)))
require.NoError(t, err)
require.Contains(t, string(descriptor), "formatVersion: 1")
require.Contains(t, string(descriptor), `select: agents[id="support"]`)
actual, err := os.ReadFile(filepath.Join(root, "agents.py"))
require.NoError(t, err)
require.Equal(t, source, actual)
}

func TestWriteStopsWhenTheDescriptorChangedAfterPrepare(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent
support = Agent(instructions="Help with orders.")
`), 0o644))
id := syncdomain.VariationID("production", "support", "default")
store := synclocal.NewStore(root)
options := Options{
Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id,
File: "agents.py", Format: "python", Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"},
}
prepared, err := Prepare(options)
require.NoError(t, err)
// Another process writes the descriptor in the review window.
tools := []syncdomain.AttachmentRef{{Key: "search"}}
_, err = store.WriteDescriptor(id, syncdomain.Reference{
File: "agents.py", Format: "python", Select: "agents[0]",
Map: map[string]string{"prompt": "instructions"}, Tools: &tools,
})
require.NoError(t, err)
other, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path)))
require.NoError(t, err)

_, err = prepared.Write(store)

require.Error(t, err)
problem, ok := syncdomain.ProblemFrom(err)
require.True(t, ok)
require.Equal(t, syncdomain.ErrorStateChanged, problem.Code)
current, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path)))
require.NoError(t, err)
require.Equal(t, other, current, "the other write stays")

// A new Prepare reads the current descriptor, so its Write proceeds.
prepared, err = Prepare(options)
require.NoError(t, err)
_, err = prepared.Write(store)
require.NoError(t, err)
}

func TestRollbackLeavesADescriptorThatAnotherProcessWrote(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent
support = Agent(instructions="Help with orders.")
`), 0o644))
id := syncdomain.VariationID("production", "support", "default")
store := synclocal.NewStore(root)
prepared, err := Prepare(Options{
Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id,
File: "agents.py", Format: "python", Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"},
})
require.NoError(t, err)
// Another process creates the descriptor in the review window.
_, err = store.WriteDescriptor(id, syncdomain.Reference{
File: "agents.py", Format: "python", Select: "agents[0]", Map: map[string]string{"prompt": "instructions"},
})
require.NoError(t, err)
other, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path)))
require.NoError(t, err)
_, err = prepared.Write(store)
require.Error(t, err, "the write refuses the descriptor of the other process")

require.NoError(t, prepared.Rollback(store))

current, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path)))
require.NoError(t, err)
require.Equal(t, other, current, "the rollback leaves the descriptor of the other process")
}

func TestPrepareKeepsTheToolsAndSkillsOfTheCurrentDescriptor(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent
support = Agent(instructions="Help with orders.")
`), 0o644))
id := syncdomain.VariationID("production", "support", "default")
store := synclocal.NewStore(root)
tools := []syncdomain.AttachmentRef{{Key: "search"}}
skills := []syncdomain.AttachmentRef{{Key: "handoff"}}
_, err := store.WriteDescriptor(id, syncdomain.Reference{
File: "agents.py", Format: "python", Select: "agents[0]",
Map: map[string]string{"prompt": "instructions"}, Tools: &tools, Skills: &skills,
})
require.NoError(t, err)

prepared, err := Prepare(Options{
Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id,
File: "agents.py", Format: "python", Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"},
})

require.NoError(t, err)
require.Equal(t, `agents[id="support"]`, prepared.Reference.Select, "the relink changes the selection")
require.Equal(t, &tools, prepared.Reference.Tools, "the relink keeps the tools")
require.Equal(t, &skills, prepared.Reference.Skills, "the relink keeps the skills")
require.Contains(t, string(prepared.Content), "key: search")
require.Contains(t, string(prepared.Content), "key: handoff")
}

func TestRollbackAfterARefusedWriteLeavesAnIdenticalDescriptor(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent
support = Agent(instructions="Help with orders.")
`), 0o644))
id := syncdomain.VariationID("production", "support", "default")
store := synclocal.NewStore(root)
options := Options{
Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id,
File: "agents.py", Format: "python", Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"},
}
prepared, err := Prepare(options)
require.NoError(t, err)
// Another process links the same resource with the same options in the
// review window, so its descriptor has the same content.
other, err := Prepare(options)
require.NoError(t, err)
_, err = other.Write(store)
require.NoError(t, err)
_, err = prepared.Write(store)
require.Error(t, err, "the write refuses the descriptor of the other process")

require.NoError(t, prepared.Rollback(store))

current, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path)))
require.NoError(t, err)
require.Equal(t, other.Content, current, "a rollback without a write of its own removes nothing")
}

func TestRollbackWithoutAWriteLeavesTheDescriptor(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent
support = Agent(instructions="Help with orders.")
`), 0o644))
id := syncdomain.VariationID("production", "support", "default")
store := synclocal.NewStore(root)
options := Options{
Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id,
File: "agents.py", Format: "python", Select: `agents[id="support"]`,
Map: map[string]string{"prompt": "instructions"},
}
prepared, err := Prepare(options)
require.NoError(t, err)
other, err := Prepare(options)
require.NoError(t, err)
_, err = other.Write(store)
require.NoError(t, err)

require.NoError(t, prepared.Rollback(store))

require.FileExists(t, filepath.Join(root, filepath.FromSlash(prepared.Path)))
}
Loading