Merge pull request #4373 from cli/ext-bin-upgrade

binary extensions list & upgrade
This commit is contained in:
Nate Smith 2021-09-29 16:03:05 -05:00 committed by GitHub
commit af812e2bdc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 337 additions and 97 deletions

View file

@ -132,7 +132,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
if len(args) > 0 {
name = normalizeExtensionSelector(args[0])
}
return m.Upgrade(name, flagForce, io.Out, io.ErrOut)
return m.Upgrade(name, flagForce)
},
}
cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions")

View file

@ -1,7 +1,6 @@
package extension
import (
"io"
"io/ioutil"
"net/http"
"os"
@ -94,7 +93,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension",
args: []string{"upgrade", "hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error {
em.UpgradeFunc = func(name string, force bool) error {
return nil
}
return func(t *testing.T) {
@ -108,7 +107,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension gh-prefix",
args: []string{"upgrade", "gh-hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error {
em.UpgradeFunc = func(name string, force bool) error {
return nil
}
return func(t *testing.T) {
@ -122,7 +121,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension full name",
args: []string{"upgrade", "monalisa/gh-hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error {
em.UpgradeFunc = func(name string, force bool) error {
return nil
}
return func(t *testing.T) {
@ -136,7 +135,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade all",
args: []string{"upgrade", "--all"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error {
em.UpgradeFunc = func(name string, force bool) error {
return nil
}
return func(t *testing.T) {

View file

@ -5,6 +5,8 @@ import (
"strings"
)
const manifestName = "manifest.yml"
type Extension struct {
path string
url string

View file

@ -70,7 +70,7 @@ func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string)
return api.HandleHTTPError(resp)
}
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755)
f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"io/ioutil"
"net/http"
"os"
@ -116,36 +117,96 @@ func (m *Manager) list(includeMetadata bool) ([]extensions.Extension, error) {
if !strings.HasPrefix(f.Name(), "gh-") {
continue
}
var remoteUrl string
updateAvailable := false
isLocal := false
exePath := filepath.Join(dir, f.Name(), f.Name())
if f.IsDir() {
if includeMetadata {
remoteUrl = m.getRemoteUrl(f.Name())
updateAvailable = m.checkUpdateAvailable(f.Name())
}
} else {
isLocal = true
if !isSymlink(f.Mode()) {
// if this is a regular file, its contents is the local directory of the extension
p, err := readPathFromFile(filepath.Join(dir, f.Name()))
if err != nil {
return nil, err
}
exePath = filepath.Join(p, f.Name())
}
ext, err := m.parseExtensionDir(f, includeMetadata)
if err != nil {
return nil, err
}
results = append(results, &Extension{
path: exePath,
url: remoteUrl,
isLocal: isLocal,
updateAvailable: updateAvailable,
})
results = append(results, ext)
}
return results, nil
}
func (m *Manager) parseExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) {
id := m.installDir()
if _, err := os.Stat(filepath.Join(id, fi.Name(), manifestName)); err == nil {
return m.parseBinaryExtensionDir(fi, includeMetadata)
}
return m.parseGitExtensionDir(fi, includeMetadata)
}
func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) {
id := m.installDir()
exePath := filepath.Join(id, fi.Name(), fi.Name())
manifestPath := filepath.Join(id, fi.Name(), manifestName)
manifest, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
}
var bm binManifest
err = yaml.Unmarshal(manifest, &bm)
if err != nil {
return nil, fmt.Errorf("could not parse %s: %w", manifestPath, err)
}
repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host)
var remoteURL string
var updateAvailable bool
if includeMetadata {
remoteURL = ghrepo.GenerateRepoURL(repo, "")
var r *release
r, err = fetchLatestRelease(m.client, repo)
if err != nil {
return nil, fmt.Errorf("failed to get release info for %s: %w", ghrepo.FullName(repo), err)
}
if bm.Tag != r.Tag {
updateAvailable = true
}
}
return &Extension{
path: exePath,
url: remoteURL,
updateAvailable: updateAvailable,
}, nil
}
func (m *Manager) parseGitExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) {
// TODO untangle local from this since local might be binary or git
id := m.installDir()
var remoteUrl string
updateAvailable := false
isLocal := false
exePath := filepath.Join(id, fi.Name(), fi.Name())
if fi.IsDir() {
if includeMetadata {
remoteUrl = m.getRemoteUrl(fi.Name())
updateAvailable = m.checkUpdateAvailable(fi.Name())
}
} else {
isLocal = true
if !isSymlink(fi.Mode()) {
// if this is a regular file, its contents is the local directory of the extension
p, err := readPathFromFile(filepath.Join(id, fi.Name()))
if err != nil {
return nil, err
}
exePath = filepath.Join(p, fi.Name())
}
}
return &Extension{
path: exePath,
url: remoteUrl,
isLocal: isLocal,
updateAvailable: updateAvailable,
}, nil
}
func (m *Manager) getRemoteUrl(extension string) string {
gitExe, err := m.lookPath("git")
if err != nil {
@ -273,7 +334,7 @@ func (m *Manager) installBin(repo ghrepo.Interface) error {
return fmt.Errorf("failed to serialize manifest: %w", err)
}
manifestPath := filepath.Join(targetDir, "manifest.yml")
manifestPath := filepath.Join(targetDir, manifestName)
f, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
@ -306,7 +367,7 @@ func (m *Manager) installGit(cloneURL string, stdout, stderr io.Writer) error {
var localExtensionUpgradeError = errors.New("local extensions can not be upgraded")
func (m *Manager) Upgrade(name string, force bool, stdout, stderr io.Writer) error {
func (m *Manager) Upgrade(name string, force bool) error {
exe, err := m.lookPath("git")
if err != nil {
return err
@ -320,41 +381,82 @@ func (m *Manager) Upgrade(name string, force bool, stdout, stderr io.Writer) err
someUpgraded := false
for _, f := range exts {
if name == "" {
fmt.Fprintf(stdout, "[%s]: ", f.Name())
fmt.Fprintf(m.io.Out, "[%s]: ", f.Name())
} else if f.Name() != name {
continue
}
if f.IsLocal() {
if name == "" {
fmt.Fprintf(stdout, "%s\n", localExtensionUpgradeError)
fmt.Fprintf(m.io.Out, "%s\n", localExtensionUpgradeError)
} else {
err = localExtensionUpgradeError
}
continue
}
var cmds []*exec.Cmd
dir := filepath.Dir(f.Path())
if force {
fetchCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "fetch", "origin", "HEAD")
resetCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "reset", "--hard", "origin/HEAD")
cmds = []*exec.Cmd{fetchCmd, resetCmd}
} else {
pullCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "pull", "--ff-only")
cmds = []*exec.Cmd{pullCmd}
binManifestPath := filepath.Join(filepath.Dir(f.Path()), manifestName)
if _, e := os.Stat(binManifestPath); e == nil {
err = m.upgradeBin(f)
someUpgraded = true
continue
}
if e := runCmds(cmds, stdout, stderr); e != nil {
if e := m.upgradeGit(f, exe, force); e != nil {
err = e
}
someUpgraded = true
}
if err == nil && !someUpgraded {
err = fmt.Errorf("no extension matched %q", name)
}
return err
}
func (m *Manager) upgradeGit(ext extensions.Extension, exe string, force bool) error {
var cmds []*exec.Cmd
dir := filepath.Dir(ext.Path())
if force {
fetchCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "fetch", "origin", "HEAD")
resetCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "reset", "--hard", "origin/HEAD")
cmds = []*exec.Cmd{fetchCmd, resetCmd}
} else {
pullCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "pull", "--ff-only")
cmds = []*exec.Cmd{pullCmd}
}
return runCmds(cmds, m.io.Out, m.io.ErrOut)
}
func (m *Manager) upgradeBin(ext extensions.Extension) error {
manifestPath := filepath.Join(filepath.Dir(ext.Path()), manifestName)
manifest, err := os.ReadFile(manifestPath)
if err != nil {
return fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
}
var bm binManifest
err = yaml.Unmarshal(manifest, &bm)
if err != nil {
return fmt.Errorf("could not parse %s: %w", manifestPath, err)
}
repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host)
var r *release
r, err = fetchLatestRelease(m.client, repo)
if err != nil {
return fmt.Errorf("failed to get release info for %s: %w", ghrepo.FullName(repo), err)
}
if bm.Tag == r.Tag {
return nil
}
return m.installBin(repo)
}
func (m *Manager) Remove(name string) error {
targetDir := filepath.Join(m.installDir(), "gh-"+name)
if _, err := os.Lstat(targetDir); os.IsNotExist(err) {

View file

@ -59,11 +59,59 @@ func TestManager_List(t *testing.T) {
assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello")))
assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two")))
assert.NoError(t, stubBinaryExtension(
filepath.Join(tempDir, "extensions", "gh-bin-ext"),
binManifest{
Owner: "owner",
Name: "gh-bin-ext",
Host: "example.com",
Tag: "v1.0.1",
}))
m := newTestManager(tempDir, nil, nil)
exts := m.List(false)
assert.Equal(t, 2, len(exts))
assert.Equal(t, "hello", exts[0].Name())
assert.Equal(t, "two", exts[1].Name())
assert.Equal(t, 3, len(exts))
assert.Equal(t, "bin-ext", exts[0].Name())
assert.Equal(t, "hello", exts[1].Name())
assert.Equal(t, "two", exts[2].Name())
}
func TestManager_List_binary_update(t *testing.T) {
tempDir := t.TempDir()
assert.NoError(t, stubBinaryExtension(
filepath.Join(tempDir, "extensions", "gh-bin-ext"),
binManifest{
Owner: "owner",
Name: "gh-bin-ext",
Host: "example.com",
Tag: "v1.0.1",
}))
reg := httpmock.Registry{}
defer reg.Verify(t)
client := http.Client{Transport: &reg}
reg.Register(
httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"),
httpmock.JSONResponse(
release{
Tag: "v1.0.2",
Assets: []releaseAsset{
{
Name: "gh-bin-ext-windows-amd64",
APIURL: "https://example.com/release/cool2",
},
},
}))
m := newTestManager(tempDir, &client, nil)
exts := m.List(true)
assert.Equal(t, 1, len(exts))
assert.Equal(t, "bin-ext", exts[0].Name())
assert.True(t, exts[0].UpdateAvailable())
assert.Equal(t, "https://example.com/owner/gh-bin-ext", exts[0].URL())
}
func TestManager_Dispatch(t *testing.T) {
@ -108,11 +156,11 @@ func TestManager_Upgrade_AllExtensions(t *testing.T) {
assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two")))
assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local")))
m := newTestManager(tempDir, nil, nil)
io, _, stdout, stderr := iostreams.Test()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := m.Upgrade("", false, stdout, stderr)
m := newTestManager(tempDir, nil, io)
err := m.Upgrade("", false)
assert.NoError(t, err)
assert.Equal(t, heredoc.Docf(
@ -133,11 +181,11 @@ func TestManager_Upgrade_RemoteExtension(t *testing.T) {
tempDir := t.TempDir()
assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote")))
m := newTestManager(tempDir, nil, nil)
io, _, stdout, stderr := iostreams.Test()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := m.Upgrade("remote", false, stdout, stderr)
m := newTestManager(tempDir, nil, io)
err := m.Upgrade("remote", false)
assert.NoError(t, err)
assert.Equal(t, heredoc.Docf(
`
@ -153,11 +201,10 @@ func TestManager_Upgrade_LocalExtension(t *testing.T) {
tempDir := t.TempDir()
assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local")))
m := newTestManager(tempDir, nil, nil)
io, _, stdout, stderr := iostreams.Test()
m := newTestManager(tempDir, nil, io)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := m.Upgrade("local", false, stdout, stderr)
err := m.Upgrade("local", false)
assert.EqualError(t, err, "local extensions can not be upgraded")
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
@ -170,11 +217,10 @@ func TestManager_Upgrade_Force(t *testing.T) {
assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote")))
m := newTestManager(tempDir, nil, nil)
io, _, stdout, stderr := iostreams.Test()
m := newTestManager(tempDir, nil, io)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := m.Upgrade("remote", true, stdout, stderr)
err := m.Upgrade("remote", true)
assert.NoError(t, err)
assert.Equal(t, heredoc.Docf(
`
@ -192,16 +238,85 @@ func TestManager_Upgrade_Force(t *testing.T) {
func TestManager_Upgrade_NoExtensions(t *testing.T) {
tempDir := t.TempDir()
m := newTestManager(tempDir, nil, nil)
io, _, stdout, stderr := iostreams.Test()
m := newTestManager(tempDir, nil, io)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := m.Upgrade("", false, stdout, stderr)
err := m.Upgrade("", false)
assert.EqualError(t, err, "no extensions installed")
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_Upgrade_BinaryExtension(t *testing.T) {
tempDir := t.TempDir()
io, _, _, _ := iostreams.Test()
reg := httpmock.Registry{}
defer reg.Verify(t)
client := http.Client{Transport: &reg}
assert.NoError(t, stubBinaryExtension(
filepath.Join(tempDir, "extensions", "gh-bin-ext"),
binManifest{
Owner: "owner",
Name: "gh-bin-ext",
Host: "example.com",
Tag: "v1.0.1",
}))
m := newTestManager(tempDir, &client, io)
reg.Register(
httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"),
httpmock.JSONResponse(
release{
Tag: "v1.0.2",
Assets: []releaseAsset{
{
Name: "gh-bin-ext-windows-amd64",
APIURL: "https://example.com/release/cool2",
},
},
}))
reg.Register(
httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"),
httpmock.JSONResponse(
release{
Tag: "v1.0.2",
Assets: []releaseAsset{
{
Name: "gh-bin-ext-windows-amd64",
APIURL: "https://example.com/release/cool2",
},
},
}))
reg.Register(
httpmock.REST("GET", "release/cool2"),
httpmock.StringResponse("FAKE UPGRADED BINARY"))
err := m.Upgrade("bin-ext", false)
assert.NoError(t, err)
manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName))
assert.NoError(t, err)
var bm binManifest
err = yaml.Unmarshal(manifest, &bm)
assert.NoError(t, err)
assert.Equal(t, binManifest{
Name: "gh-bin-ext",
Owner: "owner",
Host: "example.com",
Tag: "v1.0.2",
Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext"),
}, bm)
fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext"))
assert.NoError(t, err)
assert.Equal(t, "FAKE UPGRADED BINARY", string(fakeBin))
}
func TestManager_Install_git(t *testing.T) {
tempDir := t.TempDir()
@ -322,7 +437,7 @@ func TestManager_Install_binary(t *testing.T) {
err := m.Install(repo)
assert.NoError(t, err)
manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/manifest.yml"))
manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName))
assert.NoError(t, err)
var bm binManifest
@ -401,3 +516,37 @@ func stubLocalExtension(tempDir, path string) error {
}
return f.Close()
}
// Given the path where an extension should be installed and a manifest struct, creates a fake binary extension on disk
func stubBinaryExtension(installPath string, bm binManifest) error {
if err := os.MkdirAll(installPath, 0755); err != nil {
return err
}
fakeBinaryPath := filepath.Join(installPath, filepath.Base(installPath))
fb, err := os.OpenFile(fakeBinaryPath, os.O_CREATE, 0755)
if err != nil {
return err
}
err = fb.Close()
if err != nil {
return err
}
bs, err := yaml.Marshal(bm)
if err != nil {
return fmt.Errorf("failed to serialize manifest: %w", err)
}
manifestPath := filepath.Join(installPath, manifestName)
fm, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open manifest for writing: %w", err)
}
_, err = fm.Write(bs)
if err != nil {
return fmt.Errorf("failed write manifest file: %w", err)
}
return fm.Close()
}

View file

@ -8,8 +8,8 @@ import (
//go:generate moq -rm -out extension_mock.go . Extension
type Extension interface {
Name() string
Path() string
Name() string // Extension Name without gh-
Path() string // Path to executable
URL() string
IsLocal() bool
UpdateAvailable() bool
@ -20,7 +20,7 @@ type ExtensionManager interface {
List(includeMetadata bool) []Extension
Install(ghrepo.Interface) error
InstallLocal(dir string) error
Upgrade(name string, force bool, stdout, stderr io.Writer) error
Upgrade(name string, force bool) error
Remove(name string) error
Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error)
Create(name string) error

View file

@ -37,7 +37,7 @@ var _ ExtensionManager = &ExtensionManagerMock{}
// RemoveFunc: func(name string) error {
// panic("mock out the Remove method")
// },
// UpgradeFunc: func(name string, force bool, stdout io.Writer, stderr io.Writer) error {
// UpgradeFunc: func(name string, force bool) error {
// panic("mock out the Upgrade method")
// },
// }
@ -66,7 +66,7 @@ type ExtensionManagerMock struct {
RemoveFunc func(name string) error
// UpgradeFunc mocks the Upgrade method.
UpgradeFunc func(name string, force bool, stdout io.Writer, stderr io.Writer) error
UpgradeFunc func(name string, force bool) error
// calls tracks calls to the methods.
calls struct {
@ -112,10 +112,6 @@ type ExtensionManagerMock struct {
Name string
// Force is the force argument value.
Force bool
// Stdout is the stdout argument value.
Stdout io.Writer
// Stderr is the stderr argument value.
Stderr io.Writer
}
}
lockCreate sync.RWMutex
@ -326,41 +322,33 @@ func (mock *ExtensionManagerMock) RemoveCalls() []struct {
}
// Upgrade calls UpgradeFunc.
func (mock *ExtensionManagerMock) Upgrade(name string, force bool, stdout io.Writer, stderr io.Writer) error {
func (mock *ExtensionManagerMock) Upgrade(name string, force bool) error {
if mock.UpgradeFunc == nil {
panic("ExtensionManagerMock.UpgradeFunc: method is nil but ExtensionManager.Upgrade was just called")
}
callInfo := struct {
Name string
Force bool
Stdout io.Writer
Stderr io.Writer
Name string
Force bool
}{
Name: name,
Force: force,
Stdout: stdout,
Stderr: stderr,
Name: name,
Force: force,
}
mock.lockUpgrade.Lock()
mock.calls.Upgrade = append(mock.calls.Upgrade, callInfo)
mock.lockUpgrade.Unlock()
return mock.UpgradeFunc(name, force, stdout, stderr)
return mock.UpgradeFunc(name, force)
}
// UpgradeCalls gets all the calls that were made to Upgrade.
// Check the length with:
// len(mockedExtensionManager.UpgradeCalls())
func (mock *ExtensionManagerMock) UpgradeCalls() []struct {
Name string
Force bool
Stdout io.Writer
Stderr io.Writer
Name string
Force bool
} {
var calls []struct {
Name string
Force bool
Stdout io.Writer
Stderr io.Writer
Name string
Force bool
}
mock.lockUpgrade.RLock()
calls = mock.calls.Upgrade