Repository navigation
feat(sync): prepare repository source links #997
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| 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 | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
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) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
cursor[bot] marked this conversation as resolved.
Resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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))) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.