binary extension support in gh extension install

This commit is contained in:
vilmibm 2021-09-08 17:27:06 -05:00
parent e13398f6b4
commit eeca998640
7 changed files with 425 additions and 29 deletions

View file

@ -3,10 +3,13 @@ package extension
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -102,15 +105,38 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
return err
}
if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil {
// TODO i feel like this should check for a gh-foo script
return err
}
client, err := f.HttpClient()
if err != nil {
return fmt.Errorf("could not make http client: %w", err)
}
client = api.NewCachedClient(client, time.Second*30)
isBin, err := isBinExtension(client, repo)
if err != nil {
return fmt.Errorf("could not check for binary extension: %w", err)
}
if isBin {
return m.InstallBin(client, repo)
}
hs, err := hasScript(client, repo)
if err != nil {
return err
}
if !hs {
return errors.New("extension is uninstallable: missing executable")
}
cfg, err := f.Config()
if err != nil {
return err
}
protocol, _ := cfg.Get(repo.RepoHost(), "git_protocol")
return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
return m.InstallGit(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
},
},
func() *cobra.Command {
@ -220,6 +246,26 @@ func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager,
return nil
}
func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err error) {
hs, err := hasScript(client, repo)
if err != nil || hs {
return
}
_, err = fetchLatestRelease(client, repo)
if err != nil {
httpErr, ok := err.(api.HTTPError)
if ok && httpErr.StatusCode == 404 {
err = nil
return
}
return
}
isBin = true
return
}
func normalizeExtensionSelector(n string) string {
if idx := strings.IndexRune(n, '/'); idx >= 0 {
n = n[idx+1:]

View file

@ -3,14 +3,17 @@ package extension
import (
"io"
"io/ioutil"
"net/http"
"os"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
@ -25,6 +28,7 @@ func TestNewCmdExtension(t *testing.T) {
tests := []struct {
name string
args []string
httpStubs func(*httpmock.Registry)
managerStubs func(em *extensions.ExtensionManagerMock) func(*testing.T)
isTTY bool
wantErr bool
@ -33,17 +37,22 @@ func TestNewCmdExtension(t *testing.T) {
wantStderr string
}{
{
name: "install an extension",
name: "install a git extension",
args: []string{"install", "owner/gh-some-ext"},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"),
httpmock.StringResponse("a script"))
},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.ListFunc = func(bool) []extensions.Extension {
return []extensions.Extension{}
}
em.InstallFunc = func(s string, out, errOut io.Writer) error {
em.InstallGitFunc = func(s string, out, errOut io.Writer) error {
return nil
}
return func(t *testing.T) {
installCalls := em.InstallCalls()
installCalls := em.InstallGitCalls()
assert.Equal(t, 1, len(installCalls))
assert.Equal(t, "https://github.com/owner/gh-some-ext.git", installCalls[0].URL)
listCalls := em.ListCalls()
@ -51,6 +60,33 @@ func TestNewCmdExtension(t *testing.T) {
}
},
},
{
name: "install a binary extension",
args: []string{"install", "owner/gh-bin-ext"},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/owner/gh-bin-ext/contents/gh-bin-ext"),
httpmock.StatusStringResponse(404, "no"))
reg.Register(
httpmock.REST("GET", "repos/owner/gh-bin-ext/releases/latest"),
httpmock.StringResponse("{}"))
},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.ListFunc = func(bool) []extensions.Extension {
return []extensions.Extension{}
}
em.InstallBinFunc = func(_ *http.Client, _ ghrepo.Interface) error {
return nil
}
return func(t *testing.T) {
installCalls := em.InstallBinCalls()
assert.Equal(t, 1, len(installCalls))
assert.Equal(t, "gh-bin-ext", installCalls[0].Repo.RepoName())
listCalls := em.ListCalls()
assert.Equal(t, 1, len(listCalls))
}
},
},
{
name: "install an extension with same name as existing extension",
args: []string{"install", "owner/gh-existing-ext"},
@ -281,12 +317,23 @@ func TestNewCmdExtension(t *testing.T) {
assertFunc = tt.managerStubs(em)
}
reg := httpmock.Registry{}
defer reg.Verify(t)
client := http.Client{Transport: &reg}
if tt.httpStubs != nil {
tt.httpStubs(&reg)
}
f := cmdutil.Factory{
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
IOStreams: ios,
ExtensionManager: em,
HttpClient: func() (*http.Client, error) {
return &client, nil
},
}
cmd := NewCmdExtension(&f)

113
pkg/cmd/extension/http.go Normal file
View file

@ -0,0 +1,113 @@
package extension
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
)
func hasScript(httpClient *http.Client, repo ghrepo.Interface) (hs bool, err error) {
path := fmt.Sprintf("repos/%s/%s/contents/%s",
repo.RepoOwner(), repo.RepoName(), repo.RepoName())
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return
}
resp, err := httpClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return
}
if resp.StatusCode > 299 {
err = api.HandleHTTPError(resp)
return
}
hs = true
return
}
type releaseAsset struct {
Name string
APIURL string `json:"url"`
}
type release struct {
Assets []releaseAsset
}
// downloadAsset downloads a single asset to the given file path.
func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) error {
req, err := http.NewRequest("GET", asset.APIURL, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/octet-stream")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return api.HandleHTTPError(resp)
}
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
// fetchLatestRelease finds the latest published release for a repository.
func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) {
path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName())
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var r release
err = json.Unmarshal(b, &r)
if err != nil {
return nil, err
}
return &r, nil
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path"
@ -15,9 +16,11 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/findsh"
"github.com/cli/safeexec"
"gopkg.in/yaml.v3"
)
type Manager struct {
@ -25,6 +28,7 @@ type Manager struct {
lookPath func(string) (string, error)
findSh func() (string, error)
newCommand func(string, ...string) *exec.Cmd
platform func() string
}
func NewManager() *Manager {
@ -33,6 +37,9 @@ func NewManager() *Manager {
lookPath: safeexec.LookPath,
findSh: findsh.Find,
newCommand: exec.Command,
platform: func() string {
return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)
},
}
}
@ -172,7 +179,80 @@ func (m *Manager) InstallLocal(dir string) error {
return makeSymlink(dir, targetLink)
}
func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error {
type BinManifest struct {
Owner string
Name string
Host string
// TODO I may end up not using this; just thinking ahead to local installs
Path string
}
func (m *Manager) InstallBin(client *http.Client, repo ghrepo.Interface) error {
var r *release
r, err := fetchLatestRelease(client, repo)
if err != nil {
return err
}
suffix := m.platform()
var asset *releaseAsset
for _, a := range r.Assets {
if strings.HasSuffix(a.Name, suffix) {
asset = &a
break
}
}
if asset == nil {
return fmt.Errorf("%s unsupported for %s. Open an issue: `gh issue create -R%s/%s -t'Support %s'`",
repo.RepoName(),
suffix, repo.RepoOwner(), repo.RepoName(), suffix)
}
name := repo.RepoName()
targetDir := filepath.Join(m.installDir(), name)
// TODO clean this up if function errs?
err = os.MkdirAll(targetDir, 0755)
if err != nil {
return fmt.Errorf("failed to create installation directory: %w", err)
}
binPath := filepath.Join(targetDir, name)
err = downloadAsset(client, *asset, binPath)
if err != nil {
return fmt.Errorf("failed to download asset %s: %w", asset.Name, err)
}
manifest := BinManifest{
Name: name,
Owner: repo.RepoOwner(),
Host: repo.RepoHost(),
Path: binPath,
}
bs, err := yaml.Marshal(manifest)
if err != nil {
return fmt.Errorf("failed to serialize manifest: %w", err)
}
manifestPath := filepath.Join(targetDir, "manifest.yml")
f, 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)
}
defer f.Close()
_, err = f.Write(bs)
if err != nil {
return fmt.Errorf("failed write manifest file: %w", err)
}
return nil
}
func (m *Manager) InstallGit(cloneURL string, stdout, stderr io.Writer) error {
exe, err := m.lookPath("git")
if err != nil {
return err

View file

@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
@ -11,7 +12,10 @@ import (
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
func TestHelperProcess(t *testing.T) {
@ -39,6 +43,9 @@ func newTestManager(dir string) *Manager {
cmd.Env = []string{"GH_WANT_HELPER_PROCESS=1"}
return cmd
},
platform: func() string {
return "amiga-arm64"
},
}
}
@ -190,18 +197,66 @@ func TestManager_Upgrade_NoExtensions(t *testing.T) {
assert.Equal(t, "", stderr.String())
}
func TestManager_Install(t *testing.T) {
func TestManager_InstallGit(t *testing.T) {
tempDir := t.TempDir()
m := newTestManager(tempDir)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := m.Install("https://github.com/owner/gh-some-ext.git", stdout, stderr)
err := m.InstallGit("https://github.com/owner/gh-some-ext.git", stdout, stderr)
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("[git clone https://github.com/owner/gh-some-ext.git %s]\n", filepath.Join(tempDir, "extensions", "gh-some-ext")), stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_InstallBin(t *testing.T) {
repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com")
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{
Assets: []releaseAsset{
{
Name: "gh-bin-ext-amiga-arm64",
APIURL: "https://example.com/release/cool",
},
},
}))
reg.Register(
httpmock.REST("GET", "release/cool"),
httpmock.StringResponse("FAKE BINARY"))
tempDir := t.TempDir()
m := newTestManager(tempDir)
err := m.InstallBin(&client, repo)
assert.NoError(t, err)
manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/manifest.yml"))
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",
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 BINARY", string(fakeBin))
}
func TestManager_Create(t *testing.T) {
tempDir := t.TempDir()
oldWd, _ := os.Getwd()

View file

@ -2,6 +2,9 @@ package extensions
import (
"io"
"net/http"
"github.com/cli/cli/v2/internal/ghrepo"
)
//go:generate moq -rm -out extension_mock.go . Extension
@ -16,7 +19,8 @@ type Extension interface {
//go:generate moq -rm -out manager_mock.go . ExtensionManager
type ExtensionManager interface {
List(includeMetadata bool) []Extension
Install(url string, stdout, stderr io.Writer) error
InstallGit(url string, stdout, stderr io.Writer) error
InstallBin(client *http.Client, repo ghrepo.Interface) error
InstallLocal(dir string) error
Upgrade(name string, force bool, stdout, stderr io.Writer) error
Remove(name string) error

View file

@ -4,7 +4,9 @@
package extensions
import (
"github.com/cli/cli/v2/internal/ghrepo"
"io"
"net/http"
"sync"
)
@ -24,8 +26,11 @@ var _ ExtensionManager = &ExtensionManagerMock{}
// DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) {
// panic("mock out the Dispatch method")
// },
// InstallFunc: func(url string, stdout io.Writer, stderr io.Writer) error {
// panic("mock out the Install method")
// InstallBinFunc: func(client *http.Client, repo ghrepo.Interface) error {
// panic("mock out the InstallBin method")
// },
// InstallGitFunc: func(url string, stdout io.Writer, stderr io.Writer) error {
// panic("mock out the InstallGit method")
// },
// InstallLocalFunc: func(dir string) error {
// panic("mock out the InstallLocal method")
@ -52,8 +57,11 @@ type ExtensionManagerMock struct {
// DispatchFunc mocks the Dispatch method.
DispatchFunc func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error)
// InstallFunc mocks the Install method.
InstallFunc func(url string, stdout io.Writer, stderr io.Writer) error
// InstallBinFunc mocks the InstallBin method.
InstallBinFunc func(client *http.Client, repo ghrepo.Interface) error
// InstallGitFunc mocks the InstallGit method.
InstallGitFunc func(url string, stdout io.Writer, stderr io.Writer) error
// InstallLocalFunc mocks the InstallLocal method.
InstallLocalFunc func(dir string) error
@ -85,8 +93,15 @@ type ExtensionManagerMock struct {
// Stderr is the stderr argument value.
Stderr io.Writer
}
// Install holds details about calls to the Install method.
Install []struct {
// InstallBin holds details about calls to the InstallBin method.
InstallBin []struct {
// Client is the client argument value.
Client *http.Client
// Repo is the repo argument value.
Repo ghrepo.Interface
}
// InstallGit holds details about calls to the InstallGit method.
InstallGit []struct {
// URL is the url argument value.
URL string
// Stdout is the stdout argument value.
@ -123,7 +138,8 @@ type ExtensionManagerMock struct {
}
lockCreate sync.RWMutex
lockDispatch sync.RWMutex
lockInstall sync.RWMutex
lockInstallBin sync.RWMutex
lockInstallGit sync.RWMutex
lockInstallLocal sync.RWMutex
lockList sync.RWMutex
lockRemove sync.RWMutex
@ -204,10 +220,45 @@ func (mock *ExtensionManagerMock) DispatchCalls() []struct {
return calls
}
// Install calls InstallFunc.
func (mock *ExtensionManagerMock) Install(url string, stdout io.Writer, stderr io.Writer) error {
if mock.InstallFunc == nil {
panic("ExtensionManagerMock.InstallFunc: method is nil but ExtensionManager.Install was just called")
// InstallBin calls InstallBinFunc.
func (mock *ExtensionManagerMock) InstallBin(client *http.Client, repo ghrepo.Interface) error {
if mock.InstallBinFunc == nil {
panic("ExtensionManagerMock.InstallBinFunc: method is nil but ExtensionManager.InstallBin was just called")
}
callInfo := struct {
Client *http.Client
Repo ghrepo.Interface
}{
Client: client,
Repo: repo,
}
mock.lockInstallBin.Lock()
mock.calls.InstallBin = append(mock.calls.InstallBin, callInfo)
mock.lockInstallBin.Unlock()
return mock.InstallBinFunc(client, repo)
}
// InstallBinCalls gets all the calls that were made to InstallBin.
// Check the length with:
// len(mockedExtensionManager.InstallBinCalls())
func (mock *ExtensionManagerMock) InstallBinCalls() []struct {
Client *http.Client
Repo ghrepo.Interface
} {
var calls []struct {
Client *http.Client
Repo ghrepo.Interface
}
mock.lockInstallBin.RLock()
calls = mock.calls.InstallBin
mock.lockInstallBin.RUnlock()
return calls
}
// InstallGit calls InstallGitFunc.
func (mock *ExtensionManagerMock) InstallGit(url string, stdout io.Writer, stderr io.Writer) error {
if mock.InstallGitFunc == nil {
panic("ExtensionManagerMock.InstallGitFunc: method is nil but ExtensionManager.InstallGit was just called")
}
callInfo := struct {
URL string
@ -218,16 +269,16 @@ func (mock *ExtensionManagerMock) Install(url string, stdout io.Writer, stderr i
Stdout: stdout,
Stderr: stderr,
}
mock.lockInstall.Lock()
mock.calls.Install = append(mock.calls.Install, callInfo)
mock.lockInstall.Unlock()
return mock.InstallFunc(url, stdout, stderr)
mock.lockInstallGit.Lock()
mock.calls.InstallGit = append(mock.calls.InstallGit, callInfo)
mock.lockInstallGit.Unlock()
return mock.InstallGitFunc(url, stdout, stderr)
}
// InstallCalls gets all the calls that were made to Install.
// InstallGitCalls gets all the calls that were made to InstallGit.
// Check the length with:
// len(mockedExtensionManager.InstallCalls())
func (mock *ExtensionManagerMock) InstallCalls() []struct {
// len(mockedExtensionManager.InstallGitCalls())
func (mock *ExtensionManagerMock) InstallGitCalls() []struct {
URL string
Stdout io.Writer
Stderr io.Writer
@ -237,9 +288,9 @@ func (mock *ExtensionManagerMock) InstallCalls() []struct {
Stdout io.Writer
Stderr io.Writer
}
mock.lockInstall.RLock()
calls = mock.calls.Install
mock.lockInstall.RUnlock()
mock.lockInstallGit.RLock()
calls = mock.calls.InstallGit
mock.lockInstallGit.RUnlock()
return calls
}