Merge pull request #40 from asdf-vm/tb/plugin-repository

feat(golang-rewrite): create pluginindex package
This commit is contained in:
Trevor Brown 2024-06-03 09:03:08 -04:00 committed by Trevor Brown
commit 89ec687da9
3 changed files with 408 additions and 1 deletions

View File

@ -19,7 +19,7 @@ tidy:
audit: verify vet test
test:
go test -coverprofile=/tmp/coverage.out -bench= -v -race ./...
go test -coverprofile=/tmp/coverage.out -bench= -race ./...
cover: test
go tool cover -html=/tmp/coverage.out

209
pluginindex/pluginindex.go Normal file
View File

@ -0,0 +1,209 @@
// Package pluginindex is a package that handles fetching plugin repo URLs by
// name for user convenience.
package pluginindex
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"gopkg.in/ini.v1"
)
const (
pluginIndexDir = "plugin-index"
repoUpdatedFilename = "repo-updated"
)
type repoer interface {
Install() error
Update() error
}
type gitRepoIndex struct {
directory string
URL string
}
func (g *gitRepoIndex) Install() error {
opts := git.CloneOptions{
URL: g.URL,
}
if _, err := git.PlainClone(g.directory, false, &opts); err != nil {
return fmt.Errorf("unable to clone: %w", err)
}
return nil
}
func (g *gitRepoIndex) Update() error {
repo, err := git.PlainOpen(g.directory)
if err != nil {
return fmt.Errorf("unable to open plugin Git repository: %w", err)
}
var checkoutOptions git.CheckoutOptions
// If no ref is provided checkout latest commit on current branch
head, err := repo.Head()
if err != nil {
return fmt.Errorf("unable to get repo HEAD: %w", err)
}
if !head.Name().IsBranch() {
return fmt.Errorf("not on a branch, unable to update")
}
// If on a branch checkout the latest version of it from the remote
branch := head.Name()
ref := branch.String()
checkoutOptions = git.CheckoutOptions{Branch: branch, Force: true}
fetchOptions := git.FetchOptions{RemoteName: "origin", Force: true, RefSpecs: []config.RefSpec{
config.RefSpec(ref + ":" + ref),
}}
if err = repo.Fetch(&fetchOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return fmt.Errorf("unable to fetch from remote: %w", err)
}
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("unable to open worktree: %w", err)
}
err = worktree.Checkout(&checkoutOptions)
if err != nil {
return fmt.Errorf("unable to checkout commit: %w", err)
}
_, err = repo.ResolveRevision(plumbing.Revision("HEAD"))
if err != nil {
return fmt.Errorf("unable to get new HEAD: %w", err)
}
return nil
}
// PluginIndex is a struct representing the user's preferences for plugin index
// and the plugin index on disk.
type PluginIndex struct {
repo repoer
directory string
disableUpdate bool
updateDurationMinutes int
}
// New initializes a new PluginIndex instance with the options passed in.
func New(directory string, disableUpdate bool, updateDurationMinutes int, repo repoer) PluginIndex {
return PluginIndex{
repo: repo,
directory: directory,
disableUpdate: disableUpdate,
updateDurationMinutes: updateDurationMinutes,
}
}
// Refresh may update the plugin repo if it hasn't been updated in longer
// than updateDurationMinutes. If the plugin repo needs to be updated the
// repo will be invoked to perform the actual Git pull.
func (p PluginIndex) Refresh() (bool, error) {
err := os.MkdirAll(p.directory, os.ModePerm)
if err != nil {
return false, err
}
files, err := os.ReadDir(p.directory)
if err != nil {
return false, err
}
if len(files) == 0 {
// directory empty, clone down repo
err := p.repo.Install()
if err != nil {
return false, err
}
return touchFS(p.directory)
}
// directory must not be empty, repo must be present, maybe update
updated, err := lastUpdated(p.directory)
if err != nil {
return p.doUpdate()
}
// Convert minutes to nanoseconds
updateDurationNs := int64(p.updateDurationMinutes) * (6e10)
if updated > updateDurationNs && !p.disableUpdate {
return p.doUpdate()
}
return false, nil
}
func (p PluginIndex) doUpdate() (bool, error) {
err := p.repo.Update()
if err != nil {
return false, err
}
// Touch update file
return touchFS(p.directory)
}
// GetPluginSourceURL looks up a plugin by name and returns the repository URL
// for easy install by the user.
func (p PluginIndex) GetPluginSourceURL(name string) (string, error) {
_, err := p.Refresh()
if err != nil {
return "", err
}
url, err := readPlugin(p.directory, name)
if err != nil {
return "", err
}
return url, nil
}
func touchFS(directory string) (bool, error) {
filename := filepath.Join(directory, repoUpdatedFilename)
file, err := os.OpenFile(filename, os.O_RDONLY|os.O_CREATE, 0o666)
if err != nil {
return false, fmt.Errorf("unable to create file plugin index touch file: %w", err)
}
file.Close()
return true, nil
}
func lastUpdated(dir string) (int64, error) {
info, err := os.Stat(filepath.Join(dir, repoUpdatedFilename))
if err != nil {
return 0, fmt.Errorf("unable to read last updated file: %w", err)
}
// info.Atime_ns now contains the last access time
updated := time.Now().UnixNano() - info.ModTime().UnixNano()
return updated, nil
}
func readPlugin(dir, name string) (string, error) {
filename := filepath.Join(dir, "plugins", name)
pluginInfo, err := ini.Load(filename)
if err != nil {
return "", fmt.Errorf("no such plugin found in plugin index: %s", name)
}
return pluginInfo.Section("").Key("repository").String(), nil
}

View File

@ -0,0 +1,198 @@
package pluginindex
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
const (
realIndexURL = "https://github.com/asdf-vm/asdf-plugins.git"
badIndexURL = "http://asdf-vm.com/non-existent"
elixirPluginURL = "https://github.com/asdf-vm/asdf-elixir.git"
erlangPluginURL = "https://github.com/asdf-vm/asdf-erlang.git"
)
type MockIndex struct {
directory string
URL string
}
func (m *MockIndex) Install() error {
if m.URL == badIndexURL {
return errors.New("unable to clone: repository not found")
}
err := writeMockPluginFile(m.directory, "elixir", elixirPluginURL)
if err != nil {
return err
}
return nil
}
func (m *MockIndex) Update() error {
if m.URL == badIndexURL {
return errors.New("unable to clone: repository not found")
}
// Write another plugin file to mimic update
err := writeMockPluginFile(m.directory, "erlang", erlangPluginURL)
if err != nil {
return err
}
return nil
}
func writeMockPluginFile(dir, pluginName, pluginURL string) error {
dirname := filepath.Join(dir, "plugins")
err := os.MkdirAll(dirname, os.ModePerm)
if err != nil {
return err
}
filename := filepath.Join(dirname, pluginName)
file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, os.ModePerm)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(fmt.Sprintf("repository = %s", pluginURL))
if err != nil {
return err
}
return nil
}
func TestGetPluginSourceURL(t *testing.T) {
t.Run("with Git returns a plugin url when provided name of existing plugin", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, true, 0, &gitRepoIndex{directory: dir, URL: realIndexURL})
url, err := pluginIndex.GetPluginSourceURL("elixir")
assert.Nil(t, err)
assert.Equal(t, url, elixirPluginURL)
})
t.Run("returns a plugin url when provided name of existing plugin", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, true, 0, &MockIndex{directory: dir, URL: realIndexURL})
url, err := pluginIndex.GetPluginSourceURL("elixir")
assert.Nil(t, err)
assert.Equal(t, url, elixirPluginURL)
})
t.Run("returns a plugin url when provided name of existing plugin when loading from cache", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: realIndexURL})
url, err := pluginIndex.GetPluginSourceURL("elixir")
assert.Nil(t, err)
assert.Equal(t, url, elixirPluginURL)
url, err = pluginIndex.GetPluginSourceURL("elixir")
assert.Nil(t, err)
assert.Equal(t, url, elixirPluginURL)
})
t.Run("returns an error when given a name that isn't in the index", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: realIndexURL})
url, err := pluginIndex.GetPluginSourceURL("foobar")
assert.EqualError(t, err, "no such plugin found in plugin index: foobar")
assert.Equal(t, url, "")
})
t.Run("returns an error when plugin index cannot be updated", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: badIndexURL})
// create plain text file so it appears plugin index already exists on disk
file, err := os.OpenFile(filepath.Join(dir, "test"), os.O_RDONLY|os.O_CREATE, 0o666)
assert.Nil(t, err)
file.Close()
url, err := pluginIndex.GetPluginSourceURL("lua")
assert.EqualError(t, err, "unable to clone: repository not found")
assert.Equal(t, url, "")
})
t.Run("returns error when given non-existent plugin index", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: badIndexURL})
url, err := pluginIndex.GetPluginSourceURL("lua")
assert.EqualError(t, err, "unable to clone: repository not found")
assert.Equal(t, url, "")
})
}
func TestRefresh(t *testing.T) {
t.Run("with Git updates repo when called once", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 0, &gitRepoIndex{directory: dir, URL: realIndexURL})
url, err := pluginIndex.GetPluginSourceURL("elixir")
assert.Nil(t, err)
assert.Equal(t, url, elixirPluginURL)
updated, err := pluginIndex.Refresh()
assert.Nil(t, err)
assert.True(t, updated)
})
t.Run("updates repo when called once", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 0, &MockIndex{directory: dir, URL: realIndexURL})
updated, err := pluginIndex.Refresh()
assert.Nil(t, err)
assert.True(t, updated)
url, err := pluginIndex.GetPluginSourceURL("erlang")
assert.Nil(t, err)
assert.Equal(t, url, erlangPluginURL)
})
t.Run("does not update index when time has not elaspsed", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 10, &MockIndex{directory: dir, URL: realIndexURL})
// Call Refresh twice, the second call should not perform an update
updated, err := pluginIndex.Refresh()
assert.Nil(t, err)
assert.True(t, updated)
updated, err = pluginIndex.Refresh()
assert.Nil(t, err)
assert.False(t, updated)
})
t.Run("updates plugin index when time has elaspsed", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 0, &MockIndex{directory: dir, URL: realIndexURL})
// Call Refresh twice, the second call should perform an update
updated, err := pluginIndex.Refresh()
assert.Nil(t, err)
assert.True(t, updated)
time.Sleep(10 * time.Nanosecond)
updated, err = pluginIndex.Refresh()
assert.Nil(t, err)
assert.True(t, updated)
})
t.Run("returns error when plugin index repo doesn't exist", func(t *testing.T) {
dir := t.TempDir()
pluginIndex := New(dir, false, 0, &MockIndex{directory: dir, URL: badIndexURL})
updated, err := pluginIndex.Refresh()
assert.EqualError(t, err, "unable to clone: repository not found")
assert.False(t, updated)
})
}