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

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"

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

type Workspace struct {
Root string
Source syncdomain.Source
}

type Resolver struct {
configFile string
findGitSource func(string) (repository.GitRepository, bool, error)
ensureInstallationID func(string) (string, error)
}

func NewResolver(configFile string) Resolver {
return Resolver{
configFile: configFile,
findGitSource: repository.FindGitSource,
ensureInstallationID: config.EnsureInstallationID,
}
}

func (resolver Resolver) Resolve(dir string) (Workspace, error) {
gitRepository, found, err := resolver.findGitSource(dir)
if err != nil {
return Workspace{}, err
}
if found {
root, err := canonicalPath(gitRepository.Root)
if err != nil {
return Workspace{}, err
}

return Workspace{
Root: root,
Source: gitRepository.Source,
}, nil
}

root, err := localWorkspaceRoot(dir)
if err != nil {
return Workspace{}, err
}

installationID, err := resolver.ensureInstallationID(resolver.configFile)
if err != nil {
return Workspace{}, err
}

source, err := syncdomain.NewSource(
syncdomain.SourceTypeLocal,
localSourceIdentifier(installationID, root),
)
if err != nil {
return Workspace{}, err
}

return Workspace{Root: root, Source: source}, nil
}

func localWorkspaceRoot(dir string) (string, error) {
root, err := canonicalPath(dir)
if err != nil {
return "", err
}

for current := root; ; current = filepath.Dir(current) {
info, err := os.Stat(filepath.Join(current, syncdomain.RootDir))
switch {
case err == nil && info.IsDir():
return current, nil
case err != nil && !os.IsNotExist(err):
return "", fmt.Errorf("inspect workspace root: %w", err)
}

parent := filepath.Dir(current)
if parent == current {
return root, nil
}
}
}

func canonicalPath(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve absolute workspace path: %w", err)
}

resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return "", fmt.Errorf("resolve workspace symlinks: %w", err)
}

return filepath.Clean(resolved), nil
}

func localSourceIdentifier(installationID, root string) string {
sum := sha256.Sum256([]byte(installationID + "\x00" + root))

return "sha256." + hex.EncodeToString(sum[:])
}
154 changes: 154 additions & 0 deletions internal/sync/source/resolver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package source

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

func TestResolverUsesGitSourceWhenAvailable(t *testing.T) {
root := t.TempDir()
gitSource, err := syncdomain.NewSource(
syncdomain.SourceTypeGit,
"github.com/launchdarkly/ldcli",
)
require.NoError(t, err)

resolver := NewResolver(filepath.Join(t.TempDir(), "config.yml"))
resolver.findGitSource = func(string) (repository.GitRepository, bool, error) {
return repository.GitRepository{Root: root, Source: gitSource}, true, nil
}
resolver.ensureInstallationID = func(string) (string, error) {
t.Fatal("Git source must not create an installation ID")

return "", nil
}

workspace, err := resolver.Resolve(filepath.Join(root, "nested"))

require.NoError(t, err)
assert.Equal(t, requireCanonicalPath(t, root), workspace.Root)
assert.Equal(t, gitSource, workspace.Source)
}

func TestResolverUsesStableLocalSourceFromWorkspaceRoot(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755))
nested := filepath.Join(root, "services", "api")
require.NoError(t, os.MkdirAll(nested, 0o755))

resolver := localResolver("installation-id")

first, err := resolver.Resolve(nested)
require.NoError(t, err)
second, err := resolver.Resolve(root)
require.NoError(t, err)

expectedRoot := requireCanonicalPath(t, root)
assert.Equal(t, expectedRoot, first.Root)
assert.Equal(t, first, second)
assert.Equal(t, syncdomain.SourceTypeLocal, first.Source.Type())
assert.Equal(
t,
localSourceIdentifier("installation-id", expectedRoot),
first.Source.Identifier(),
)
assert.NotContains(t, first.Source.Identifier(), expectedRoot)

_, err = os.Stat(filepath.Join(root, syncdomain.RootDir, "source.yaml"))
assert.ErrorIs(t, err, os.ErrNotExist)
}

func TestResolverUsesCurrentDirectoryBeforeBootstrap(t *testing.T) {
root := t.TempDir()
resolver := localResolver("installation-id")

workspace, err := resolver.Resolve(root)

require.NoError(t, err)
expectedRoot := requireCanonicalPath(t, root)
assert.Equal(t, expectedRoot, workspace.Root)
assert.Equal(
t,
localSourceIdentifier("installation-id", expectedRoot),
workspace.Source.Identifier(),
)
}

func TestResolverCanonicalizesSymlinkedWorkspace(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755))

link := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.Symlink(root, link))

workspace, err := localResolver("installation-id").Resolve(link)

require.NoError(t, err)
expectedRoot := requireCanonicalPath(t, root)
assert.Equal(t, expectedRoot, workspace.Root)
assert.Equal(
t,
localSourceIdentifier("installation-id", expectedRoot),
workspace.Source.Identifier(),
)
}

func TestResolverChangesLocalIdentityWhenWorkspaceMoves(t *testing.T) {
first, err := localResolver("installation-id").Resolve(t.TempDir())
require.NoError(t, err)
second, err := localResolver("installation-id").Resolve(t.TempDir())
require.NoError(t, err)

assert.NotEqual(t, first.Source.Identifier(), second.Source.Identifier())
}

func TestResolverReturnsGitAndConfigErrors(t *testing.T) {
t.Run("invalid Git source", func(t *testing.T) {
resolver := localResolver("installation-id")
resolver.findGitSource = func(string) (repository.GitRepository, bool, error) {
return repository.GitRepository{}, false, errors.New("invalid origin")
}

_, err := resolver.Resolve(t.TempDir())
require.ErrorContains(t, err, "invalid origin")
})

t.Run("installation ID", func(t *testing.T) {
resolver := localResolver("installation-id")
resolver.ensureInstallationID = func(string) (string, error) {
return "", errors.New("config unavailable")
}

_, err := resolver.Resolve(t.TempDir())
require.ErrorContains(t, err, "config unavailable")
})
}

func localResolver(installationID string) Resolver {
resolver := NewResolver("config.yml")
resolver.findGitSource = func(string) (repository.GitRepository, bool, error) {
return repository.GitRepository{}, false, nil
}
resolver.ensureInstallationID = func(string) (string, error) {
return installationID, nil
}

return resolver
}

func requireCanonicalPath(t *testing.T, path string) string {
t.Helper()

resolved, err := canonicalPath(path)
require.NoError(t, err)

return resolved
}
Loading