Skip to content

Commit

Permalink
chore: add self update command
Browse files Browse the repository at this point in the history
feat: add self update
  • Loading branch information
marianozunino committed Oct 10, 2024
1 parent a199b59 commit b74ffeb
Show file tree
Hide file tree
Showing 5 changed files with 371 additions and 15 deletions.
130 changes: 130 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
Copyright © 2024 Mariano Zunino <marianoz@posteo.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd

import (
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/Masterminds/semver/v3"
"github.com/marianozunino/sdm-ui/internal/logger"
"github.com/minio/selfupdate"
"github.com/rs/zerolog/log"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)

var updateCmd = &cobra.Command{
Use: "update",
Short: "Update sdm-ui to the latest version",
Run: func(cmd *cobra.Command, args []string) {
logger.ConfigureLogger(confData.Verbose)
if err := runSelfUpdate(http.DefaultClient, afero.NewOsFs(), os.Args[0]); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
},
}

func init() {
rootCmd.AddCommand(updateCmd)
}

func getAssetName(version string) string {
os := runtime.GOOS
arch := runtime.GOARCH

switch os {
case "darwin":
os = "Darwin"
case "linux":
os = "Linux"
}

switch arch {
case "amd64":
arch = "x86_64"
case "386":
arch = "i386"
}

return fmt.Sprintf("sdm-ui_%s_%s_%s.tar.gz", version, os, arch)
}

func runSelfUpdate(httpClient *http.Client, fs afero.Fs, executablePath string) error {
log.Info().Msg("Checking for updates...")

releaseURL := "https://github.com/marianozunino/sdm-ui/releases/latest"

resp, err := httpClient.Get(releaseURL)
if err != nil {
return fmt.Errorf("error checking for updates: %v", err)
}
defer resp.Body.Close()

latestVersionStr := filepath.Base(resp.Request.URL.Path)
latestVersionStr = strings.TrimPrefix(latestVersionStr, "v")
currentVersionStr := strings.TrimPrefix(Version, "v")

latestVersion, err := semver.NewVersion(latestVersionStr)
if err != nil {
return fmt.Errorf("error parsing latest version: %v", err)
}

currentVersion, err := semver.NewVersion(currentVersionStr)
if err != nil {
return fmt.Errorf("error parsing current version: %v", err)
}

if !latestVersion.GreaterThan(currentVersion) {
log.Info().Msg("Current version is the latest")
return nil
}

log.Info().Msgf("New version available: %s (current: %s)", latestVersion, currentVersion)

assetName := getAssetName(latestVersion.String())
downloadURL := fmt.Sprintf("https://github.com/marianozunino/sdm-ui/releases/download/v%s/%s", latestVersion, assetName)

log.Debug().Msgf("Downloading %s from %s", assetName, downloadURL)
resp, err = httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("error downloading update: %v", err)
}
defer resp.Body.Close()

log.Info().Msg("Applying update...")
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
if rerr := selfupdate.RollbackError(err); rerr != nil {
return fmt.Errorf("failed to rollback from bad update: %v", rerr)
}
return fmt.Errorf("error updating binary: %v", err)
}

log.Info().Msgf("Successfully updated to version %s", latestVersion)
return nil
}
178 changes: 178 additions & 0 deletions cmd/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package cmd

import (
"fmt"
"net/http"
"strings"
"testing"

"github.com/Masterminds/semver/v3"
"github.com/jarcoal/httpmock"
"github.com/spf13/afero"
)

// Helper function to set up HTTP mock responses for version checking and updates
func setupMockResponses(latestVersion, assetVersion string, withUpdate bool) {
httpmock.RegisterResponder("GET", "https://github.com/marianozunino/sdm-ui/releases/latest",
func(req *http.Request) (*http.Response, error) {
resp := httpmock.NewStringResponse(302, "")
resp.Header.Set("Location", fmt.Sprintf("https://github.com/marianozunino/sdm-ui/releases/tag/v%s", latestVersion))
resp.Request = req
return resp, nil
})

httpmock.RegisterResponder("GET", fmt.Sprintf("https://github.com/marianozunino/sdm-ui/releases/tag/v%s", latestVersion),
httpmock.NewStringResponder(200, ""))

if withUpdate {
assetName := getAssetName(assetVersion)
downloadURL := fmt.Sprintf("https://github.com/marianozunino/sdm-ui/releases/download/v%s/%s", assetVersion, assetName)
httpmock.RegisterResponder("GET", downloadURL,
httpmock.NewBytesResponder(200, []byte("mock binary data")))
}
}

func TestVersionComparison(t *testing.T) {
tests := []struct {
current string
latest string
needsUpdate bool
}{
{"1.0.0", "1.0.1", true},
{"1.0.0", "1.1.0", true},
{"1.0.0", "2.0.0", true},
{"1.0.0", "1.0.0", false},
{"1.1.0", "1.0.0", false},
{"2.0.0", "1.9.9", false},
{"1.0.0-alpha", "1.0.0", true},
{"1.0.0", "1.0.1-alpha", true},
{"1.0.0-beta", "1.0.0-alpha", false},
{"v1.0.0", "1.0.1", true},
{"1.0.0", "v1.0.1", true},
{"v1.0.0", "v1.0.0", false},
}

for _, test := range tests {
current, err := parseVersion(test.current)
if err != nil {
t.Errorf("Error parsing current version %s: %v", test.current, err)
continue
}

latest, err := parseVersion(test.latest)
if err != nil {
t.Errorf("Error parsing latest version %s: %v", test.latest, err)
continue
}

if result := latest.GreaterThan(current); result != test.needsUpdate {
t.Errorf("Version comparison failed for current: %s, latest: %s. Expected needsUpdate: %v, got: %v",
test.current, test.latest, test.needsUpdate, result)
}
}
}

// Helper function to parse semantic versions
func parseVersion(versionStr string) (*semver.Version, error) {
version, err := semver.NewVersion(versionStr)
if err != nil {
return nil, fmt.Errorf("error parsing version: %v", err)
}
return version, nil
}

// Modified verifyHTTPCalls function to accept `t *testing.T`
func verifyHTTPCalls(t *testing.T, expectUpdate bool, latestVersion string) {
info := httpmock.GetCallCountInfo()

// Verify latest release check
if info["GET https://github.com/marianozunino/sdm-ui/releases/latest"] != 1 {
t.Error("Expected one call to check the latest release")
}

// If update expected, verify download URL call
if expectUpdate {
assetName := getAssetName(latestVersion)
downloadURL := fmt.Sprintf("https://github.com/marianozunino/sdm-ui/releases/download/v%s/%s", latestVersion, assetName)
if info["GET "+downloadURL] != 1 {
t.Error("Expected one call to download the update")
}
}
}

// TestRunSelfUpdate modified to pass `t` into verifyHTTPCalls
func TestRunSelfUpdate(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()

fs := afero.NewMemMapFs()
executablePath := "/path/to/sdm-ui"

tests := []struct {
name string
currentVersion string
latestVersion string
expectUpdate bool
mockResponses func()
expectedError string
}{
{
name: "No update available",
currentVersion: "1.0.0",
latestVersion: "1.0.0",
expectUpdate: false,
mockResponses: func() { setupMockResponses("1.0.0", "1.0.0", false) },
},
{
name: "Update available",
currentVersion: "1.0.0",
latestVersion: "1.1.0",
expectUpdate: true,
mockResponses: func() { setupMockResponses("1.1.0", "1.1.0", true) },
},
{
name: "Invalid current version",
currentVersion: "invalid",
latestVersion: "1.0.0",
expectUpdate: false,
mockResponses: func() { setupMockResponses("1.0.0", "1.0.0", false) },
expectedError: "error parsing current version",
},
{
name: "Invalid latest version",
currentVersion: "1.0.0",
latestVersion: "invalid",
expectUpdate: false,
mockResponses: func() {
httpmock.RegisterResponder("GET", "https://github.com/marianozunino/sdm-ui/releases/latest",
httpmock.NewStringResponder(200, "https://github.com/marianozunino/sdm-ui/releases/tag/invalid"))
},
expectedError: "error parsing latest version",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
httpmock.Reset()
tc.mockResponses()

Version = tc.currentVersion
err := runSelfUpdate(http.DefaultClient, fs, executablePath)

if tc.expectedError != "" {
if err == nil || !strings.Contains(err.Error(), tc.expectedError) {
t.Errorf("Expected error containing '%s', got '%v'", tc.expectedError, err)
}
} else {
if tc.expectUpdate && err != nil {
t.Errorf("Expected successful update, got error: %v", err)
} else if !tc.expectUpdate && err != nil {
t.Errorf("Expected no update, got error: %v", err)
}
}

// Pass t into verifyHTTPCalls
verifyHTTPCalls(t, tc.expectUpdate, tc.latestVersion)
})
}
}
9 changes: 7 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ go 1.21.6

require (
git.sr.ht/~marianozunino/go-rofi v0.3.0
github.com/Masterminds/semver/v3 v3.3.0
github.com/adrg/xdg v0.5.0
github.com/jarcoal/httpmock v1.3.1
github.com/minio/selfupdate v0.6.0
github.com/rs/zerolog v1.33.0
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/zalando/go-keyring v0.2.3
github.com/zyedidia/clipper v0.1.1
go.etcd.io/bbolt v1.3.8
golang.org/x/term v0.18.0
)

require (
aead.dev/minisign v0.2.0 // indirect
github.com/akavel/rsrc v0.10.2 // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
Expand Down Expand Up @@ -47,14 +53,13 @@ require (
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 // indirect
Expand Down
Loading

0 comments on commit b74ffeb

Please sign in to comment.