From d5f5ec89a2c818fd0330a21b570bfdf6ddbc7b0c Mon Sep 17 00:00:00 2001 From: Aravind Shankar Date: Wed, 19 Dec 2018 17:51:33 +0530 Subject: [PATCH 01/10] WIP --- cli/commands/update.go | 21 +++++++++++++++++++++ cli/update/update.go | 1 + 2 files changed, 22 insertions(+) create mode 100644 cli/commands/update.go create mode 100644 cli/update/update.go diff --git a/cli/commands/update.go b/cli/commands/update.go new file mode 100644 index 0000000000000..bb1b9a7d67191 --- /dev/null +++ b/cli/commands/update.go @@ -0,0 +1,21 @@ +package commands + +import ( + "github.com/hasura/graphql-engine/cli" + "github.com/spf13/cobra" +) + +// NewUpdateCmd checks and update to lastest graphql-engine cli +func NewUpdateCmd(ec *cli.ExecutionContext) *cobra.Command { + updateCmd := &cobra.Command{ + Use: "update-cli", + Short: "Update graphql-engine CLI tool to latest version", + SilenceUsage: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + return ec.Prepare() + }, + RunE: func(cmd *cobra.Command, args []string) error { + cliVersion := ec.Version.GetCLIVersion() + }, + } +} diff --git a/cli/update/update.go b/cli/update/update.go new file mode 100644 index 0000000000000..7a7e4d4731dd7 --- /dev/null +++ b/cli/update/update.go @@ -0,0 +1 @@ +package update From 8a23803bbf5d25473bd7a1063fa5d7d9dee31bfa Mon Sep 17 00:00:00 2001 From: Aravind Shankar Date: Thu, 20 Dec 2018 19:22:27 +0530 Subject: [PATCH 02/10] added update-cli command, close #1239 --- cli/Gopkg.lock | 8 ++- cli/Gopkg.toml | 4 ++ cli/commands/root.go | 1 + cli/commands/update.go | 38 ++++++++++++++- cli/update/github_release.go | 94 ++++++++++++++++++++++++++++++++++++ cli/update/update.go | 64 ++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 cli/update/github_release.go diff --git a/cli/Gopkg.lock b/cli/Gopkg.lock index 0a7653f30869d..f7039ad412919 100644 --- a/cli/Gopkg.lock +++ b/cli/Gopkg.lock @@ -180,6 +180,12 @@ ] revision = "720a0952cc2ac777afc295d9861263e2a4cf96a1" +[[projects]] + branch = "master" + name = "github.com/kardianos/osext" + packages = ["."] + revision = "ae77be60afb1dcacde03767a8c37337fad28ac14" + [[projects]] branch = "master" name = "github.com/lib/pq" @@ -409,6 +415,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "81fcfffa8a80b8583e2e0904bb40d9d8cac3a889a2965d0c68896871578acf73" + inputs-digest = "6abaaaf45d044084979a00f8bdf9d2e4f3cdcbe8c5a496789447e21e233af0ef" solver-name = "gps-cdcl" solver-version = 1 diff --git a/cli/Gopkg.toml b/cli/Gopkg.toml index 64f98c7c328e6..d57ea077e56a0 100644 --- a/cli/Gopkg.toml +++ b/cli/Gopkg.toml @@ -91,3 +91,7 @@ [[constraint]] branch = "master" name = "github.com/oliveagle/jsonpath" + +[[constraint]] + branch = "master" + name = "github.com/kardianos/osext" diff --git a/cli/commands/root.go b/cli/commands/root.go index f1c53c90a2738..6f31c9114d3f5 100644 --- a/cli/commands/root.go +++ b/cli/commands/root.go @@ -24,6 +24,7 @@ func init() { NewMigrateCmd(ec), NewVersionCmd(ec), NewDocsCmd(ec), + NewUpdateCmd(ec), ) f := rootCmd.PersistentFlags() f.StringVar(&ec.LogLevel, "log-level", "INFO", "log level (DEBUG, INFO, WARN, ERROR, FATAL)") diff --git a/cli/commands/update.go b/cli/commands/update.go index bb1b9a7d67191..b4fb1b8b5c7d6 100644 --- a/cli/commands/update.go +++ b/cli/commands/update.go @@ -1,12 +1,18 @@ package commands import ( + "os" + "github.com/hasura/graphql-engine/cli" + "github.com/hasura/graphql-engine/cli/update" "github.com/spf13/cobra" ) // NewUpdateCmd checks and update to lastest graphql-engine cli func NewUpdateCmd(ec *cli.ExecutionContext) *cobra.Command { + opts := &updateOptions{ + EC: ec, + } updateCmd := &cobra.Command{ Use: "update-cli", Short: "Update graphql-engine CLI tool to latest version", @@ -15,7 +21,37 @@ func NewUpdateCmd(ec *cli.ExecutionContext) *cobra.Command { return ec.Prepare() }, RunE: func(cmd *cobra.Command, args []string) error { - cliVersion := ec.Version.GetCLIVersion() + return opts.run() }, } + return updateCmd +} + +type updateOptions struct { + EC *cli.ExecutionContext +} + +func (o *updateOptions) run() error { + isUpdate, releaseInfo, asset, err := update.CheckUpdate(o.EC.Version.GetCLIVersion()) + if err != nil { + return err + } + + if !isUpdate { + o.EC.Logger.WithField("version", o.EC.Version.GetCLIVersion()).Info("hasura is up to date") + return nil + } + + err = update.ApplyUpdate(asset) + if err != nil { + if os.IsPermission(err) { + o.EC.Logger.Fatal(`# permission denied, try in admin mode or sudo: + $ sudo hasura update-cli`) + return nil + } + return err + } + + o.EC.Logger.WithField("version", releaseInfo.TagName).Info("hasura updated to latest version") + return nil } diff --git a/cli/update/github_release.go b/cli/update/github_release.go new file mode 100644 index 0000000000000..68fab78ee30af --- /dev/null +++ b/cli/update/github_release.go @@ -0,0 +1,94 @@ +package update + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "time" +) + +// Asset structure from GitHub API +type Asset struct { + ID int `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Size int `json:"size"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (a *Asset) DownloadBinary(tmpFile string, path string) (file *os.File, err error) { + response, err := http.Get(a.BrowserDownloadURL) + if err != nil { + return nil, err + } + defer response.Body.Close() + + file, err = ioutil.TempFile(path, tmpFile) + if err != nil { + return + } + defer file.Close() + + _, err = io.Copy(file, response.Body) + if err != nil { + return nil, err + } + + err = file.Chmod(0755) + if err != nil { + return nil, err + } + + return file, nil +} + +// Release structure from GitHub API +type Release struct { + ID int `json:"id"` + URL string `json:"url"` + AssetURL string `json:"asset_url"` + UploadURL string `json:"upload_url"` + HTMLURL string `json:"html_url"` + TagName string `json:"tag_name"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` + Assets []Asset `json:"assets"` +} + +func (r *Release) GetAsset(fileName string) (assetToDownload *Asset) { + for _, asset := range r.Assets { + if asset.Name == fileName { + assetToDownload = &asset + break + } + } + return +} + +const githubReleaseURL string = "https://api.github.com/repos/%s/%s/releases" + +func getLatestRelease(repoOwner string, repoName string) (releaseInfo []*Release, err error) { + resp, err := http.Get(fmt.Sprintf(githubReleaseURL, repoOwner, repoName)) + if err != nil { + return + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return + } + + err = json.Unmarshal(body, &releaseInfo) + if err != nil { + return + } + return +} diff --git a/cli/update/update.go b/cli/update/update.go index 7a7e4d4731dd7..b783ce9d642dc 100644 --- a/cli/update/update.go +++ b/cli/update/update.go @@ -1 +1,65 @@ package update + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/kardianos/osext" +) + +const ( + AppRepoOwner string = "hasura" + AppName string = "graphql-engine" +) + +func CheckUpdate(version string) (bool, *Release, *Asset, error) { + releases, err := getLatestRelease(AppRepoOwner, AppName) + if err != nil { + return false, nil, nil, err + } + + if len(releases) == 0 { + return false, nil, nil, nil + } + + releaseInfo := releases[0] + + if releaseInfo.TagName != version { + assetToDownload := releaseInfo.GetAsset(buildFilename()) + if assetToDownload == nil { + return false, releaseInfo, nil, nil + } + return true, releaseInfo, assetToDownload, nil + } + return false, releaseInfo, nil, nil +} + +func ApplyUpdate(asset *Asset) error { + currentExecutable, err := osext.Executable() + if err != nil { + return err + } + + exPath := filepath.Dir(currentExecutable) + + file, err := asset.DownloadBinary(buildFilename(), exPath) + if err != nil { + return err + } + + err = os.Rename(file.Name(), currentExecutable) + if err != nil { + return err + } + return nil +} + +func buildFilename() string { + extension := "" + if runtime.GOOS == "windows" { + extension = ".exe" + } + return fmt.Sprintf("cli-%s-%s-%s%s", AppRepoOwner, runtime.GOOS, runtime.GOARCH, extension) +} From 363b3903b24b3d871a8f6a354bb7f7e0e726803f Mon Sep 17 00:00:00 2001 From: Aravind Shankar Date: Thu, 3 Jan 2019 15:51:25 +0530 Subject: [PATCH 03/10] update description text --- cli/commands/update.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/commands/update.go b/cli/commands/update.go index b4fb1b8b5c7d6..ab5111ef5c9d1 100644 --- a/cli/commands/update.go +++ b/cli/commands/update.go @@ -15,7 +15,7 @@ func NewUpdateCmd(ec *cli.ExecutionContext) *cobra.Command { } updateCmd := &cobra.Command{ Use: "update-cli", - Short: "Update graphql-engine CLI tool to latest version", + Short: "Update the Hasura CLI to latest version", SilenceUsage: true, PreRunE: func(cmd *cobra.Command, args []string) error { return ec.Prepare() @@ -32,12 +32,12 @@ type updateOptions struct { } func (o *updateOptions) run() error { - isUpdate, releaseInfo, asset, err := update.CheckUpdate(o.EC.Version.GetCLIVersion()) + hasUpdate, releaseInfo, asset, err := update.CheckUpdate(o.EC.Version.GetCLIVersion()) if err != nil { return err } - if !isUpdate { + if !hasUpdate { o.EC.Logger.WithField("version", o.EC.Version.GetCLIVersion()).Info("hasura is up to date") return nil } From d367ae9b8aae7cacc7bb759eb844970c4264aa31 Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Fri, 4 Jan 2019 16:12:02 +0530 Subject: [PATCH 04/10] change messaging --- cli/commands/{update.go => update-cli.go} | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) rename cli/commands/{update.go => update-cli.go} (58%) diff --git a/cli/commands/update.go b/cli/commands/update-cli.go similarity index 58% rename from cli/commands/update.go rename to cli/commands/update-cli.go index ab5111ef5c9d1..d0c9dc6921ca7 100644 --- a/cli/commands/update.go +++ b/cli/commands/update-cli.go @@ -1,10 +1,12 @@ package commands import ( + "fmt" "os" "github.com/hasura/graphql-engine/cli" "github.com/hasura/graphql-engine/cli/update" + "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -32,26 +34,30 @@ type updateOptions struct { } func (o *updateOptions) run() error { - hasUpdate, releaseInfo, asset, err := update.CheckUpdate(o.EC.Version.GetCLIVersion()) + currentVersion := o.EC.Version.GetCLIVersion() + o.EC.Logger.Infof("Current version: %s", currentVersion) + o.EC.Spin("Checking for update... ") + hasUpdate, releaseInfo, asset, err := update.CheckUpdate(currentVersion) + o.EC.Spinner.Stop() if err != nil { return err } if !hasUpdate { - o.EC.Logger.WithField("version", o.EC.Version.GetCLIVersion()).Info("hasura is up to date") + o.EC.Logger.WithField("version", currentVersion).Info("CLI is up to date") return nil } + o.EC.Spin(fmt.Sprintf("Updating to %s... ", releaseInfo.TagName)) err = update.ApplyUpdate(asset) + o.EC.Spinner.Stop() if err != nil { if os.IsPermission(err) { - o.EC.Logger.Fatal(`# permission denied, try in admin mode or sudo: - $ sudo hasura update-cli`) - return nil + return errors.New("permission denied, try again as admin or with sudo") } - return err + return errors.Wrap(err, "apply update") } - o.EC.Logger.WithField("version", releaseInfo.TagName).Info("hasura updated to latest version") + o.EC.Logger.WithField("version", releaseInfo.TagName).Info("Updated to latest version") return nil } From 2d7025b5b7b6f2a217685d7bc35d85005ecae69d Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Thu, 31 Jan 2019 08:57:33 +0530 Subject: [PATCH 05/10] add update-cli command --- cli/Gopkg.lock | 38 ++++++++++- cli/Makefile | 3 +- cli/commands/root.go | 2 +- cli/commands/update-cli.go | 25 +++---- cli/get.sh | 5 +- cli/update/github_release.go | 94 -------------------------- cli/update/update.go | 124 ++++++++++++++++++++++++++--------- 7 files changed, 149 insertions(+), 142 deletions(-) delete mode 100644 cli/update/github_release.go diff --git a/cli/Gopkg.lock b/cli/Gopkg.lock index 148bacfcd45ab..16abd1fed343d 100644 --- a/cli/Gopkg.lock +++ b/cli/Gopkg.lock @@ -236,8 +236,10 @@ [[projects]] branch = "master" + digest = "1:caf6db28595425c0e0f2301a00257d11712f65c1878e12cffc42f6b9a9cf3f23" name = "github.com/kardianos/osext" packages = ["."] + pruneopts = "UT" revision = "ae77be60afb1dcacde03767a8c37337fad28ac14" [[projects]] @@ -321,11 +323,14 @@ [[projects]] branch = "master" + digest = "1:4f971e961cc101712d18dbbd532813bbcd08c9984acceb55dff3fb86ed62ed5f" name = "github.com/oliveagle/jsonpath" packages = ["."] + pruneopts = "UT" revision = "2e52cf6e685269ed8aefec6390f3cac3ef74e730" [[projects]] + digest = "1:d776f3e95774a8719f2e57fabbbb33103035fe072dcf6f1864f33abd17b753e5" name = "github.com/parnurzeal/gorequest" packages = ["."] pruneopts = "UT" @@ -528,6 +533,37 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "6abaaaf45d044084979a00f8bdf9d2e4f3cdcbe8c5a496789447e21e233af0ef" + input-imports = [ + "github.com/Masterminds/semver", + "github.com/briandowns/spinner", + "github.com/docker/docker/api/types", + "github.com/docker/docker/api/types/container", + "github.com/docker/docker/api/types/network", + "github.com/docker/docker/client", + "github.com/docker/go-connections/nat", + "github.com/elazarl/go-bindata-assetfs", + "github.com/fatih/color", + "github.com/ghodss/yaml", + "github.com/gin-contrib/cors", + "github.com/gin-contrib/static", + "github.com/gin-gonic/contrib/renders/multitemplate", + "github.com/gin-gonic/gin", + "github.com/gofrs/uuid", + "github.com/kardianos/osext", + "github.com/lib/pq", + "github.com/manifoldco/promptui", + "github.com/mattn/go-colorable", + "github.com/mitchellh/go-homedir", + "github.com/oliveagle/jsonpath", + "github.com/parnurzeal/gorequest", + "github.com/pkg/errors", + "github.com/sirupsen/logrus", + "github.com/sirupsen/logrus/hooks/test", + "github.com/skratchdot/open-golang/open", + "github.com/spf13/cobra", + "github.com/spf13/cobra/doc", + "github.com/spf13/viper", + "github.com/stretchr/testify/assert", + ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/cli/Makefile b/cli/Makefile index 335c8ff6a4ebc..cc7e9d84d3dbf 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,6 +1,7 @@ PWD := $(shell pwd) PARENT_DIR := $(shell dirname $(PWD)) VERSION ?= $(shell ../scripts/get-version.sh) +OS ?= linux darwin windows OUTPUT_DIR := _output # compile assets @@ -32,7 +33,7 @@ build: export CGO_ENABLED=0 build: gox -ldflags '-X github.com/hasura/graphql-engine/cli/version.BuildVersion=$(VERSION) -s -w -extldflags "-static"' \ -rebuild \ - -os="linux darwin windows" \ + -os="$(OS)" \ -arch="amd64" \ -output="$(OUTPUT_DIR)/$(VERSION)/cli-hasura-{{.OS}}-{{.Arch}}" \ ./cmd/hasura/ diff --git a/cli/commands/root.go b/cli/commands/root.go index 4a56820ca825e..f55e828e47ae0 100644 --- a/cli/commands/root.go +++ b/cli/commands/root.go @@ -30,7 +30,7 @@ func init() { NewMigrateCmd(ec), NewVersionCmd(ec), NewDocsCmd(ec), - NewUpdateCmd(ec), + NewUpdateCLICmd(ec), ) f := rootCmd.PersistentFlags() f.StringVar(&ec.LogLevel, "log-level", "INFO", "log level (DEBUG, INFO, WARN, ERROR, FATAL)") diff --git a/cli/commands/update-cli.go b/cli/commands/update-cli.go index d0c9dc6921ca7..24a04b25c9f53 100644 --- a/cli/commands/update-cli.go +++ b/cli/commands/update-cli.go @@ -10,14 +10,14 @@ import ( "github.com/spf13/cobra" ) -// NewUpdateCmd checks and update to lastest graphql-engine cli -func NewUpdateCmd(ec *cli.ExecutionContext) *cobra.Command { +// NewUpdateCLICmd returns the update-cli command. +func NewUpdateCLICmd(ec *cli.ExecutionContext) *cobra.Command { opts := &updateOptions{ EC: ec, } updateCmd := &cobra.Command{ Use: "update-cli", - Short: "Update the Hasura CLI to latest version", + Short: "Update the CLI to latest version", SilenceUsage: true, PreRunE: func(cmd *cobra.Command, args []string) error { return ec.Prepare() @@ -34,22 +34,25 @@ type updateOptions struct { } func (o *updateOptions) run() error { - currentVersion := o.EC.Version.GetCLIVersion() - o.EC.Logger.Infof("Current version: %s", currentVersion) + currentVersion := o.EC.Version.CLISemver + if currentVersion == nil { + return errors.Errorf("cannot update from a non-semver version: %s", o.EC.Version.GetCLIVersion()) + } + o.EC.Spin("Checking for update... ") - hasUpdate, releaseInfo, asset, err := update.CheckUpdate(currentVersion) + hasUpdate, latestVersion, err := update.HasUpdate(currentVersion) o.EC.Spinner.Stop() if err != nil { - return err + return errors.Wrap(err, "command: check update") } if !hasUpdate { - o.EC.Logger.WithField("version", currentVersion).Info("CLI is up to date") + o.EC.Logger.WithField("version", currentVersion).Info("hasura cli is up to date") return nil } - o.EC.Spin(fmt.Sprintf("Updating to %s... ", releaseInfo.TagName)) - err = update.ApplyUpdate(asset) + o.EC.Spin(fmt.Sprintf("Updating cli to %s... ", latestVersion.String())) + err = update.ApplyUpdate(latestVersion) o.EC.Spinner.Stop() if err != nil { if os.IsPermission(err) { @@ -58,6 +61,6 @@ func (o *updateOptions) run() error { return errors.Wrap(err, "apply update") } - o.EC.Logger.WithField("version", releaseInfo.TagName).Info("Updated to latest version") + o.EC.Logger.WithField("version", latestVersion.String()).Info("Updated to latest version") return nil } diff --git a/cli/get.sh b/cli/get.sh index 379ae29a7db57..f80f461bfc9a3 100755 --- a/cli/get.sh +++ b/cli/get.sh @@ -17,7 +17,7 @@ hasCli() { if [ "$?" = "0" ]; then echo echo "You already have the hasura cli!" - export n=1 + export n=3 echo "Overwriting in $n seconds.. Press Control+C to cancel." echo sleep $n @@ -42,10 +42,9 @@ getPackage() { ;; "Linux") arch=$(uname -m) - echo $arch case $arch in "amd64" | "x86_64") - suffix="-linux-amd4" + suffix="-linux-amd64" ;; esac case $arch in diff --git a/cli/update/github_release.go b/cli/update/github_release.go deleted file mode 100644 index 68fab78ee30af..0000000000000 --- a/cli/update/github_release.go +++ /dev/null @@ -1,94 +0,0 @@ -package update - -import ( - "encoding/json" - "fmt" - "io" - "io/ioutil" - "net/http" - "os" - "time" -) - -// Asset structure from GitHub API -type Asset struct { - ID int `json:"id"` - URL string `json:"url"` - Name string `json:"name"` - Size int `json:"size"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - BrowserDownloadURL string `json:"browser_download_url"` -} - -func (a *Asset) DownloadBinary(tmpFile string, path string) (file *os.File, err error) { - response, err := http.Get(a.BrowserDownloadURL) - if err != nil { - return nil, err - } - defer response.Body.Close() - - file, err = ioutil.TempFile(path, tmpFile) - if err != nil { - return - } - defer file.Close() - - _, err = io.Copy(file, response.Body) - if err != nil { - return nil, err - } - - err = file.Chmod(0755) - if err != nil { - return nil, err - } - - return file, nil -} - -// Release structure from GitHub API -type Release struct { - ID int `json:"id"` - URL string `json:"url"` - AssetURL string `json:"asset_url"` - UploadURL string `json:"upload_url"` - HTMLURL string `json:"html_url"` - TagName string `json:"tag_name"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` - PublishedAt time.Time `json:"published_at"` - Prerelease bool `json:"prerelease"` - Assets []Asset `json:"assets"` -} - -func (r *Release) GetAsset(fileName string) (assetToDownload *Asset) { - for _, asset := range r.Assets { - if asset.Name == fileName { - assetToDownload = &asset - break - } - } - return -} - -const githubReleaseURL string = "https://api.github.com/repos/%s/%s/releases" - -func getLatestRelease(repoOwner string, repoName string) (releaseInfo []*Release, err error) { - resp, err := http.Get(fmt.Sprintf(githubReleaseURL, repoOwner, repoName)) - if err != nil { - return - } - - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return - } - - err = json.Unmarshal(body, &releaseInfo) - if err != nil { - return - } - return -} diff --git a/cli/update/update.go b/cli/update/update.go index b783ce9d642dc..18e6b4959c454 100644 --- a/cli/update/update.go +++ b/cli/update/update.go @@ -1,65 +1,127 @@ package update import ( + "encoding/json" "fmt" + "io" + "io/ioutil" + "net/http" "os" "path/filepath" "runtime" "github.com/kardianos/osext" -) -const ( - AppRepoOwner string = "hasura" - AppName string = "graphql-engine" + "github.com/Masterminds/semver" + "github.com/pkg/errors" ) -func CheckUpdate(version string) (bool, *Release, *Asset, error) { - releases, err := getLatestRelease(AppRepoOwner, AppName) +const updateCheckURL = "https://releases.hasura.io/graphql-engine?agent=cli" + +type updateCheckResponse struct { + Latest string `json:"latest"` +} + +func getLatestVersion() (*semver.Version, error) { + res, err := http.Get(updateCheckURL) + if err != nil { + return nil, errors.Wrap(err, "update check request") + } + + defer res.Body.Close() + var response updateCheckResponse + err = json.NewDecoder(res.Body).Decode(&response) if err != nil { - return false, nil, nil, err + return nil, errors.Wrap(err, "decoding update check response") } - if len(releases) == 0 { - return false, nil, nil, nil + v, err := semver.NewVersion(response.Latest) + if err != nil { + return nil, errors.Wrap(err, "semver parsing") } - releaseInfo := releases[0] + return v, nil +} - if releaseInfo.TagName != version { - assetToDownload := releaseInfo.GetAsset(buildFilename()) - if assetToDownload == nil { - return false, releaseInfo, nil, nil - } - return true, releaseInfo, assetToDownload, nil +func buildAssetURL(v string) string { + os := runtime.GOOS + arch := runtime.GOARCH + extension := "" + if os == "windows" { + extension = ".exe" } - return false, releaseInfo, nil, nil + return fmt.Sprintf( + "https://github.com/hasura/graphql-engine/releases/download/v%s/cli-hasura-%s-%s%s", + v, os, arch, extension, + ) } -func ApplyUpdate(asset *Asset) error { - currentExecutable, err := osext.Executable() +func downloadAsset(url, fileName, filePath string) (*os.File, error) { + res, err := http.Get(url) if err != nil { - return err + return nil, errors.Wrap(err, "downloading asset") } + defer res.Body.Close() - exPath := filepath.Dir(currentExecutable) + if res.StatusCode != 200 { + return nil, errors.New("could not find the release asset") + } - file, err := asset.DownloadBinary(buildFilename(), exPath) + asset, err := ioutil.TempFile(filePath, fileName) if err != nil { - return err + return nil, errors.Wrap(err, "creating temporary file") } + defer asset.Close() - err = os.Rename(file.Name(), currentExecutable) + _, err = io.Copy(asset, res.Body) if err != nil { - return err + return nil, errors.Wrap(err, "saving downloaded file") } - return nil + + err = asset.Chmod(0755) + if err != nil { + return nil, errors.Wrap(err, "changing downloaded file permissions") + } + + return asset, nil } -func buildFilename() string { - extension := "" - if runtime.GOOS == "windows" { - extension = ".exe" +// HasUpdate tells us if there is a new update available. +func HasUpdate(currentVersion *semver.Version) (bool, *semver.Version, error) { + latestVersion, err := getLatestVersion() + if err != nil { + return false, nil, errors.Wrap(err, "get latest version") + } + + c, err := semver.NewConstraint(fmt.Sprintf("> %s", currentVersion.String())) + if err != nil { + return false, nil, errors.Wrap(err, "semver constraint build") + } + + return c.Check(latestVersion), latestVersion, nil +} + +// ApplyUpdate downloads and applies the update. +func ApplyUpdate(v *semver.Version) error { + exe, err := osext.Executable() + if err != nil { + return errors.Wrap(err, "find executable") } - return fmt.Sprintf("cli-%s-%s-%s%s", AppRepoOwner, runtime.GOOS, runtime.GOARCH, extension) + + exePath := filepath.Dir(exe) + exeName := filepath.Base(exe) + + asset, err := downloadAsset( + buildAssetURL(v.String()), exeName+".new", exePath, + ) + if err != nil { + return errors.Wrap(err, "download asset") + } + + err = os.Rename(asset.Name(), exe) + if err != nil { + return errors.Wrap(err, "rename asset") + } + + return nil } From 27fe23e833f98a86b7773a68d84b5116fdf353c7 Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Thu, 31 Jan 2019 16:23:52 +0530 Subject: [PATCH 06/10] fixes for windows --- cli/update/hide_noop.go | 10 ++++++ cli/update/hide_windows.go | 22 ++++++++++++ cli/update/update.go | 70 ++++++++++++++++++++++++++++++++------ 3 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 cli/update/hide_noop.go create mode 100644 cli/update/hide_windows.go diff --git a/cli/update/hide_noop.go b/cli/update/hide_noop.go new file mode 100644 index 0000000000000..ca508a044903f --- /dev/null +++ b/cli/update/hide_noop.go @@ -0,0 +1,10 @@ +// +build !windows + +package update + +// shamelessly copied from +// https://github.com/inconshreveable/go-update/blob/master/hide_windows.go + +func hideFile(path string) error { + return nil +} diff --git a/cli/update/hide_windows.go b/cli/update/hide_windows.go new file mode 100644 index 0000000000000..cef2ad0359f88 --- /dev/null +++ b/cli/update/hide_windows.go @@ -0,0 +1,22 @@ +package update + +// shamelessly copied from +// https://github.com/inconshreveable/go-update/blob/master/hide_windows.go + +import ( + "syscall" + "unsafe" +) + +func hideFile(path string) error { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + setFileAttributes := kernel32.NewProc("SetFileAttributesW") + + r1, _, err := setFileAttributes.Call(uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(path))), 2) + + if r1 == 0 { + return err + } else { + return nil + } +} diff --git a/cli/update/update.go b/cli/update/update.go index 18e6b4959c454..2006e8c3e70fd 100644 --- a/cli/update/update.go +++ b/cli/update/update.go @@ -1,10 +1,12 @@ package update +// adapted from +// https://github.com/inconshreveable/go-update + import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "os" "path/filepath" @@ -67,9 +69,13 @@ func downloadAsset(url, fileName, filePath string) (*os.File, error) { return nil, errors.New("could not find the release asset") } - asset, err := ioutil.TempFile(filePath, fileName) + asset, err := os.OpenFile( + filepath.Join(filePath, fileName), + os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + 0755, + ) if err != nil { - return nil, errors.Wrap(err, "creating temporary file") + return nil, errors.Wrap(err, "creating new binary file") } defer asset.Close() @@ -78,11 +84,6 @@ func downloadAsset(url, fileName, filePath string) (*os.File, error) { return nil, errors.Wrap(err, "saving downloaded file") } - err = asset.Chmod(0755) - if err != nil { - return nil, errors.Wrap(err, "changing downloaded file permissions") - } - return asset, nil } @@ -103,24 +104,71 @@ func HasUpdate(currentVersion *semver.Version) (bool, *semver.Version, error) { // ApplyUpdate downloads and applies the update. func ApplyUpdate(v *semver.Version) error { + // get the current executable exe, err := osext.Executable() if err != nil { return errors.Wrap(err, "find executable") } + // extract the filename and path exePath := filepath.Dir(exe) exeName := filepath.Base(exe) + // download the new binary asset, err := downloadAsset( - buildAssetURL(v.String()), exeName+".new", exePath, + buildAssetURL(v.String()), "."+exeName+".new", exePath, ) if err != nil { return errors.Wrap(err, "download asset") } - err = os.Rename(asset.Name(), exe) + // get the downloaded binary name and build the absolute path + newExe := asset.Name() + + // build name and absolute path for saving old binary + oldExeName := "." + exeName + ".old" + oldExe := filepath.Join(exePath, oldExeName) + + // delete any existing old binary file - this is necessary on Windows for two reasons: + // 1. after a successful update, Windows can't remove the .old file because the process is still running + // 2. windows rename operations fail if the destination file already exists + _ = os.Remove(oldExe) + + // rename the current binary as old binary + err = os.Rename(exe, oldExe) + if err != nil { + return errors.Wrap(err, "rename exe to old") + } + + // rename the new binary as the current binary + err = os.Rename(newExe, exe) if err != nil { - return errors.Wrap(err, "rename asset") + // rename unsuccessfull + // + // The filesystem is now in a bad state. We have successfully + // moved the existing binary to a new location, but we couldn't move the new + // binary to take its place. That means there is no file where the current executable binary + // used to be! + // Try to rollback by restoring the old binary to its original path. + rerr := os.Rename(oldExe, exe) + if rerr != nil { + // rolling back failed, ask user to re-install cli + return errors.Wrap( + rerr, + "rename old to exe: inconsistent state, re-install cli", + ) + } + // rolled back, throw update error + return errors.Wrap(err, "rename new to exe") + } + + // rename success, remove the old binary + errRemove := os.Remove(oldExe) + + // windows has trouble removing old binaries, so hide it instead + // it will be removed next time this code runs. + if errRemove != nil { + _ = hideFile(oldExe) } return nil From f7e55c2496fc64af340d7bc7db074259c4bfc732 Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Thu, 31 Jan 2019 19:18:23 +0530 Subject: [PATCH 07/10] split code into multiple files --- cli/cli.go | 210 -------------------------------------- cli/directory.go | 96 +++++++++++++++++ cli/global_config.go | 136 ++++++++++++++++++++++++ cli/update/auto_update.go | 1 + 4 files changed, 233 insertions(+), 210 deletions(-) create mode 100644 cli/directory.go create mode 100644 cli/global_config.go create mode 100644 cli/update/auto_update.go diff --git a/cli/cli.go b/cli/cli.go index a1796850fccee..3d9f9935d3d64 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -8,13 +8,9 @@ package cli import ( - "encoding/json" - "io/ioutil" "net/url" "os" "path/filepath" - "regexp" - "strings" "time" "github.com/hasura/graphql-engine/cli/telemetry" @@ -24,7 +20,6 @@ import ( "github.com/gofrs/uuid" "github.com/hasura/graphql-engine/cli/version" colorable "github.com/mattn/go-colorable" - homedir "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/viper" @@ -76,15 +71,6 @@ func (hgc *HasuraGraphQLConfig) ParseEndpoint() error { return nil } -// GlobalConfig is the configuration object stored in the GlobalConfigFile. -type GlobalConfig struct { - // UUID used for telemetry, generated on first run. - UUID string `json:"uuid"` - - // Indicate if telemetry is enabled or not - EnableTelemetry bool `json:"enable_telemetry"` -} - // ExecutionContext contains various contextual information required by the cli // at various points of it's execution. Values are filled in by the // initializers and passed on to each command. Commands can also fill in values @@ -266,36 +252,6 @@ func (ec *ExecutionContext) checkServerVersion() error { return nil } -// readGlobalConfig reads the configuration from global config file env vars, -// through viper. -func (ec *ExecutionContext) readGlobalConfig() error { - // need to get existing viper because https://github.com/spf13/viper/issues/233 - v := viper.New() - v.SetEnvPrefix("HASURA_GRAPHQL") - v.AutomaticEnv() - v.SetConfigName("config") - v.AddConfigPath(ec.GlobalConfigDir) - err := v.ReadInConfig() - if err != nil { - return errors.Wrap(err, "cannor read global config from file/env") - } - if ec.GlobalConfig == nil { - ec.Logger.Debugf("global config is not pre-set, reading from current env") - ec.GlobalConfig = &GlobalConfig{ - UUID: v.GetString("uuid"), - EnableTelemetry: v.GetBool("enable_telemetry"), - } - } else { - ec.Logger.Debugf("global config is pre-set to %#v", ec.GlobalConfig) - } - ec.Logger.Debugf("global config: uuid: %v", ec.GlobalConfig.UUID) - ec.Logger.Debugf("global config: enableTelemetry: %v", ec.GlobalConfig.EnableTelemetry) - // set if telemetry can be beamed or not - ec.Telemetry.CanBeam = ec.GlobalConfig.EnableTelemetry - ec.Telemetry.UUID = ec.GlobalConfig.UUID - return nil -} - // readConfig reads the configuration from config file, flags and env vars, // through viper. func (ec *ExecutionContext) readConfig() error { @@ -362,172 +318,6 @@ func (ec *ExecutionContext) setupLogger() { } } -// setupGlobConfig ensures that global config directory and file exists and -// reads it into the GlobalConfig object. -func (ec *ExecutionContext) setupGlobalConfig() error { - if len(ec.GlobalConfigDir) == 0 { - ec.Logger.Debug("global config directory is not pre-set, defaulting") - home, err := homedir.Dir() - if err != nil { - return errors.Wrap(err, "cannot get home directory") - } - globalConfigDir := filepath.Join(home, GLOBAL_CONFIG_DIR_NAME) - ec.GlobalConfigDir = globalConfigDir - ec.Logger.Debugf("global config directory set as '%s'", ec.GlobalConfigDir) - } - err := os.MkdirAll(ec.GlobalConfigDir, os.ModePerm) - if err != nil { - return errors.Wrap(err, "cannot create global config directory") - } - if len(ec.GlobalConfigFile) == 0 { - ec.GlobalConfigFile = filepath.Join(ec.GlobalConfigDir, GLOBAL_CONFIG_FILE_NAME) - ec.Logger.Debugf("global config file set as '%s'", ec.GlobalConfigFile) - } - _, err = os.Stat(ec.GlobalConfigFile) - if os.IsNotExist(err) { - // file does not exist, teat as first run and create it - ec.Logger.Debug("global config file does not exist, this could be the first run, creating it...") - u, err := uuid.NewV4() - if err != nil { - return errors.Wrap(err, "failed to generate uuid") - } - gc := GlobalConfig{ - UUID: u.String(), - EnableTelemetry: true, - } - data, err := json.MarshalIndent(gc, "", " ") - if err != nil { - return errors.Wrap(err, "cannot marshal json for config file") - } - err = ioutil.WriteFile(ec.GlobalConfigFile, data, 0644) - if err != nil { - return errors.Wrap(err, "writing global config file failed") - } - ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, string(data)) - // also show a notice about telemetry - ec.Logger.Info(StrTelemetryNotice) - } else if os.IsExist(err) || err == nil { - // file exists, verify contents - ec.Logger.Debug("global config file exisits, verifying contents") - data, err := ioutil.ReadFile(ec.GlobalConfigFile) - if err != nil { - return errors.Wrap(err, "reading global config file failed") - } - var gc GlobalConfig - err = json.Unmarshal(data, &gc) - if err != nil { - return errors.Wrap(err, "global config file not a valid json") - } - _, err = uuid.FromString(gc.UUID) - if err != nil { - ec.Logger.Debugf("invalid uuid '%s' in global config: %v", gc.UUID, err) - // create a new UUID - ec.Logger.Debug("global config file exists, but uuid is invalid, creating a new one...") - u, err := uuid.NewV4() - if err != nil { - return errors.Wrap(err, "failed to generate uuid") - } - gc.UUID = u.String() - data, err := json.Marshal(gc) - if err != nil { - return errors.Wrap(err, "cannot marshal json for config file") - } - err = ioutil.WriteFile(ec.GlobalConfigFile, data, 0644) - if err != nil { - return errors.Wrap(err, "writing global config file failed") - } - ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, string(data)) - } - } - return nil -} - -// validateDirectory sets execution directory and validate it to see that or any -// of the parent directory is a valid project directory. A valid project -// directory contains the following: -// 1. migrations directory -// 2. config.yaml file -// 3. metadata.yaml (optional) -// If the current directory or any parent directory (upto filesystem root) is -// found to have these files, ExecutionDirectory is set as that directory. -func (ec *ExecutionContext) validateDirectory() error { - if len(ec.ExecutionDirectory) == 0 { - cwd, err := os.Getwd() - if err != nil { - return errors.Wrap(err, "error getting current working directory") - } - ec.ExecutionDirectory = cwd - } - - ed, err := os.Stat(ec.ExecutionDirectory) - if err != nil { - if os.IsNotExist(err) { - return errors.Wrap(err, "did not find required directory. use 'init'?") - } else { - return errors.Wrap(err, "error getting directory details") - } - } - if !ed.IsDir() { - return errors.Errorf("'%s' is not a directory", ed.Name()) - } - // config.yaml - // migrations/ - // (optional) metadata.yaml - dir, err := recursivelyValidateDirectory(ec.ExecutionDirectory) - if err != nil { - return errors.Wrap(err, "validate") - } - - ec.ExecutionDirectory = dir - return nil -} - -// filesRequired are the files that are mandatory to qualify for a project -// directory. -var filesRequired = []string{ - "config.yaml", - "migrations", -} - -// recursivelyValidateDirectory tries to parse 'startFrom' as a project -// directory by checking for the 'filesRequired'. If the parent of 'startFrom' -// (nextDir) is filesystem root, error is returned. Otherwise, 'nextDir' is -// validated, recursively. -func recursivelyValidateDirectory(startFrom string) (validDir string, err error) { - err = validateDirectory(startFrom) - if err != nil { - nextDir := filepath.Dir(startFrom) - cleaned := filepath.Clean(nextDir) - isWindowsRoot, _ := regexp.MatchString(`^[a-zA-Z]:\\$`, cleaned) - // return error if filesystem boundary is hit - if cleaned == "/" || isWindowsRoot { - return nextDir, errors.Errorf("cannot find [%s] | search stopped at filesystem boundary", strings.Join(filesRequired, ", ")) - - } - return recursivelyValidateDirectory(nextDir) - } - return startFrom, nil -} - -// validateDirectory tries to parse dir for the filesRequired and returns error -// if any one of them is missing. -func validateDirectory(dir string) error { - notFound := []string{} - for _, f := range filesRequired { - if _, err := os.Stat(filepath.Join(dir, f)); os.IsNotExist(err) { - relpath, e := filepath.Rel(dir, f) - if e == nil { - f = relpath - } - notFound = append(notFound, f) - } - } - if len(notFound) > 0 { - return errors.Errorf("cannot validate directory '%s': [%s] not found", dir, strings.Join(notFound, ", ")) - } - return nil -} - // SetVersion sets the version inside context, according to the variable // 'version' set during build context. func (ec *ExecutionContext) setVersion() { diff --git a/cli/directory.go b/cli/directory.go new file mode 100644 index 0000000000000..55043e852928e --- /dev/null +++ b/cli/directory.go @@ -0,0 +1,96 @@ +package cli + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" +) + +// validateDirectory sets execution directory and validate it to see that or any +// of the parent directory is a valid project directory. A valid project +// directory contains the following: +// 1. migrations directory +// 2. config.yaml file +// 3. metadata.yaml (optional) +// If the current directory or any parent directory (upto filesystem root) is +// found to have these files, ExecutionDirectory is set as that directory. +func (ec *ExecutionContext) validateDirectory() error { + if len(ec.ExecutionDirectory) == 0 { + cwd, err := os.Getwd() + if err != nil { + return errors.Wrap(err, "error getting current working directory") + } + ec.ExecutionDirectory = cwd + } + + ed, err := os.Stat(ec.ExecutionDirectory) + if err != nil { + if os.IsNotExist(err) { + return errors.Wrap(err, "did not find required directory. use 'init'?") + } else { + return errors.Wrap(err, "error getting directory details") + } + } + if !ed.IsDir() { + return errors.Errorf("'%s' is not a directory", ed.Name()) + } + // config.yaml + // migrations/ + // (optional) metadata.yaml + dir, err := recursivelyValidateDirectory(ec.ExecutionDirectory) + if err != nil { + return errors.Wrap(err, "validate") + } + + ec.ExecutionDirectory = dir + return nil +} + +// filesRequired are the files that are mandatory to qualify for a project +// directory. +var filesRequired = []string{ + "config.yaml", + "migrations", +} + +// recursivelyValidateDirectory tries to parse 'startFrom' as a project +// directory by checking for the 'filesRequired'. If the parent of 'startFrom' +// (nextDir) is filesystem root, error is returned. Otherwise, 'nextDir' is +// validated, recursively. +func recursivelyValidateDirectory(startFrom string) (validDir string, err error) { + err = validateDirectory(startFrom) + if err != nil { + nextDir := filepath.Dir(startFrom) + cleaned := filepath.Clean(nextDir) + isWindowsRoot, _ := regexp.MatchString(`^[a-zA-Z]:\\$`, cleaned) + // return error if filesystem boundary is hit + if cleaned == "/" || isWindowsRoot { + return nextDir, errors.Errorf("cannot find [%s] | search stopped at filesystem boundary", strings.Join(filesRequired, ", ")) + + } + return recursivelyValidateDirectory(nextDir) + } + return startFrom, nil +} + +// validateDirectory tries to parse dir for the filesRequired and returns error +// if any one of them is missing. +func validateDirectory(dir string) error { + notFound := []string{} + for _, f := range filesRequired { + if _, err := os.Stat(filepath.Join(dir, f)); os.IsNotExist(err) { + relpath, e := filepath.Rel(dir, f) + if e == nil { + f = relpath + } + notFound = append(notFound, f) + } + } + if len(notFound) > 0 { + return errors.Errorf("cannot validate directory '%s': [%s] not found", dir, strings.Join(notFound, ", ")) + } + return nil +} diff --git a/cli/global_config.go b/cli/global_config.go new file mode 100644 index 0000000000000..344e56850bb01 --- /dev/null +++ b/cli/global_config.go @@ -0,0 +1,136 @@ +package cli + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + + "github.com/gofrs/uuid" + homedir "github.com/mitchellh/go-homedir" + "github.com/pkg/errors" + "github.com/spf13/viper" +) + +// GlobalConfig is the configuration object stored in the GlobalConfigFile. +type GlobalConfig struct { + // UUID used for telemetry, generated on first run. + UUID string `json:"uuid"` + + // Indicate if telemetry is enabled or not + EnableTelemetry bool `json:"enable_telemetry"` + + // Indicates whether update notifications should be shown or not + ShowUpdateNotification bool `json:"show_update_notification"` +} + +// setupGlobConfig ensures that global config directory and file exists and +// reads it into the GlobalConfig object. +func (ec *ExecutionContext) setupGlobalConfig() error { + if len(ec.GlobalConfigDir) == 0 { + ec.Logger.Debug("global config directory is not pre-set, defaulting") + home, err := homedir.Dir() + if err != nil { + return errors.Wrap(err, "cannot get home directory") + } + globalConfigDir := filepath.Join(home, GLOBAL_CONFIG_DIR_NAME) + ec.GlobalConfigDir = globalConfigDir + ec.Logger.Debugf("global config directory set as '%s'", ec.GlobalConfigDir) + } + err := os.MkdirAll(ec.GlobalConfigDir, os.ModePerm) + if err != nil { + return errors.Wrap(err, "cannot create global config directory") + } + if len(ec.GlobalConfigFile) == 0 { + ec.GlobalConfigFile = filepath.Join(ec.GlobalConfigDir, GLOBAL_CONFIG_FILE_NAME) + ec.Logger.Debugf("global config file set as '%s'", ec.GlobalConfigFile) + } + _, err = os.Stat(ec.GlobalConfigFile) + if os.IsNotExist(err) { + // file does not exist, teat as first run and create it + ec.Logger.Debug("global config file does not exist, this could be the first run, creating it...") + u, err := uuid.NewV4() + if err != nil { + return errors.Wrap(err, "failed to generate uuid") + } + gc := GlobalConfig{ + UUID: u.String(), + EnableTelemetry: true, + ShowUpdateNotification: true, + } + data, err := json.MarshalIndent(gc, "", " ") + if err != nil { + return errors.Wrap(err, "cannot marshal json for config file") + } + err = ioutil.WriteFile(ec.GlobalConfigFile, data, 0644) + if err != nil { + return errors.Wrap(err, "writing global config file failed") + } + ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, string(data)) + // also show a notice about telemetry + ec.Logger.Info(StrTelemetryNotice) + } else if os.IsExist(err) || err == nil { + // file exists, verify contents + ec.Logger.Debug("global config file exisits, verifying contents") + data, err := ioutil.ReadFile(ec.GlobalConfigFile) + if err != nil { + return errors.Wrap(err, "reading global config file failed") + } + var gc GlobalConfig + err = json.Unmarshal(data, &gc) + if err != nil { + return errors.Wrap(err, "global config file not a valid json") + } + _, err = uuid.FromString(gc.UUID) + if err != nil { + ec.Logger.Debugf("invalid uuid '%s' in global config: %v", gc.UUID, err) + // create a new UUID + ec.Logger.Debug("global config file exists, but uuid is invalid, creating a new one...") + u, err := uuid.NewV4() + if err != nil { + return errors.Wrap(err, "failed to generate uuid") + } + gc.UUID = u.String() + data, err := json.Marshal(gc) + if err != nil { + return errors.Wrap(err, "cannot marshal json for config file") + } + err = ioutil.WriteFile(ec.GlobalConfigFile, data, 0644) + if err != nil { + return errors.Wrap(err, "writing global config file failed") + } + ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, string(data)) + } + } + return nil +} + +// readGlobalConfig reads the configuration from global config file env vars, +// through viper. +func (ec *ExecutionContext) readGlobalConfig() error { + // need to get existing viper because https://github.com/spf13/viper/issues/233 + v := viper.New() + v.SetEnvPrefix("HASURA_GRAPHQL") + v.AutomaticEnv() + v.SetConfigName("config") + v.AddConfigPath(ec.GlobalConfigDir) + err := v.ReadInConfig() + if err != nil { + return errors.Wrap(err, "cannor read global config from file/env") + } + if ec.GlobalConfig == nil { + ec.Logger.Debugf("global config is not pre-set, reading from current env") + ec.GlobalConfig = &GlobalConfig{ + UUID: v.GetString("uuid"), + EnableTelemetry: v.GetBool("enable_telemetry"), + } + } else { + ec.Logger.Debugf("global config is pre-set to %#v", ec.GlobalConfig) + } + ec.Logger.Debugf("global config: uuid: %v", ec.GlobalConfig.UUID) + ec.Logger.Debugf("global config: enableTelemetry: %v", ec.GlobalConfig.EnableTelemetry) + // set if telemetry can be beamed or not + ec.Telemetry.CanBeam = ec.GlobalConfig.EnableTelemetry + ec.Telemetry.UUID = ec.GlobalConfig.UUID + return nil +} diff --git a/cli/update/auto_update.go b/cli/update/auto_update.go new file mode 100644 index 0000000000000..7a7e4d4731dd7 --- /dev/null +++ b/cli/update/auto_update.go @@ -0,0 +1 @@ +package update From 974b93cb4d6ec7ecf7adacce62c455c712b470cb Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Sat, 2 Feb 2019 14:13:01 +0530 Subject: [PATCH 08/10] add auto update check to every command --- cli/cli.go | 34 +++------ cli/commands/docs.go | 3 +- cli/commands/init.go | 3 +- cli/commands/root.go | 20 ++++- cli/commands/update-cli.go | 48 +++++++++++- cli/global_config.go | 146 +++++++++++++++++++++++++++---------- cli/update/auto_update.go | 39 ++++++++++ cli/update/update.go | 9 ++- 8 files changed, 229 insertions(+), 73 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index 3d9f9935d3d64..83ffef2dc8574 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -25,22 +25,15 @@ import ( "github.com/spf13/viper" ) -// Environment variable names recognised by the CLI. -const ( - // ENV_ENDPOINT is the name of env var which indicates the Hasura GraphQL - // Engine endpoint URL. - ENV_ENDPOINT = "HASURA_GRAPHQL_ENDPOINT" - // ENV_ACCESS_KEY is the name of env var that has the access key for GraphQL - // Engine endpoint. - ENV_ACCESS_KEY = "HASURA_GRAPHQL_ACCESS_KEY" -) - // Other constants used in the package const ( // Name of the global configuration directory GLOBAL_CONFIG_DIR_NAME = ".hasura" // Name of the global configuration file GLOBAL_CONFIG_FILE_NAME = "config.json" + + // Name of the file to store last update check time + LastUpdateCheckFileName = "last_update_check_at" ) // String constants @@ -127,6 +120,9 @@ type ExecutionContext struct { // Telemetry collects the telemetry data throughout the execution Telemetry *telemetry.Data + + // LastUpdateCheckFile is the file where the timestamp of last update check is stored + LastUpdateCheckFile string } // NewExecutionContext returns a new instance of execution context @@ -160,17 +156,12 @@ func (ec *ExecutionContext) Prepare() error { // setup global config err := ec.setupGlobalConfig() if err != nil { - // TODO(shahidhk): should this be a failure? - return errors.Wrap(err, "setting up global config directory failed") + return errors.Wrap(err, "setting up global config failed") } - // read global config - err = ec.readGlobalConfig() - if err != nil { - return errors.Wrap(err, "reading global config failed") - } + ec.LastUpdateCheckFile = filepath.Join(ec.GlobalConfigDir, LastUpdateCheckFileName) - // initialize a blank config + // initialize a blank server config if ec.Config == nil { ec.Config = &HasuraGraphQLConfig{} } @@ -196,14 +187,9 @@ func (ec *ExecutionContext) Prepare() error { // ExecutionDirectory to see if all the required files and directories are in // place. func (ec *ExecutionContext) Validate() error { - // prepare the context - err := ec.Prepare() - if err != nil { - return errors.Wrap(err, "failed preparing context") - } // validate execution directory - err = ec.validateDirectory() + err := ec.validateDirectory() if err != nil { return errors.Wrap(err, "validating current directory failed") } diff --git a/cli/commands/docs.go b/cli/commands/docs.go index 7584c16afbcfe..2a516a7f2eb29 100644 --- a/cli/commands/docs.go +++ b/cli/commands/docs.go @@ -18,9 +18,8 @@ func NewDocsCmd(ec *cli.ExecutionContext) *cobra.Command { Short: "Generate CLI docs in various formats", Hidden: true, SilenceUsage: true, - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRun: func(cmd *cobra.Command, args []string) { ec.Viper = viper.New() - return ec.Prepare() }, RunE: func(cmd *cobra.Command, args []string) (err error) { err = os.MkdirAll(docDirectory, os.ModePerm) diff --git a/cli/commands/init.go b/cli/commands/init.go index 129cff6690c4c..c4b062d241e12 100644 --- a/cli/commands/init.go +++ b/cli/commands/init.go @@ -38,9 +38,8 @@ func NewInitCmd(ec *cli.ExecutionContext) *cobra.Command { # See https://docs.hasura.io/1.0/graphql/manual/migrations/index.html for more details`, SilenceUsage: true, - PreRunE: func(cmd *cobra.Command, args []string) error { + PreRun: func(cmd *cobra.Command, args []string) { ec.Viper = viper.New() - return ec.Prepare() }, RunE: func(cmd *cobra.Command, args []string) error { return opts.run() diff --git a/cli/commands/root.go b/cli/commands/root.go index f55e828e47ae0..ac07ae2d846aa 100644 --- a/cli/commands/root.go +++ b/cli/commands/root.go @@ -4,6 +4,8 @@ package commands import ( "github.com/hasura/graphql-engine/cli" + "github.com/hasura/graphql-engine/cli/update" + "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -18,6 +20,18 @@ var rootCmd = &cobra.Command{ SilenceErrors: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { ec.Telemetry.Command = cmd.CommandPath() + + if cmd.Use != updateCLICmdUse { + if update.ShouldRunCheck(ec.LastUpdateCheckFile) && ec.GlobalConfig.ShowUpdateNotification { + u := &updateOptions{ + EC: ec, + } + err := u.run(true) + if err != nil { + ec.Logger.WithError(err).Error("auto-update failed, run 'hasura update-cli' to update manually") + } + } + } }, } @@ -39,7 +53,11 @@ func init() { // Execute executes the command and returns the error func Execute() error { - err := rootCmd.Execute() + err := ec.Prepare() + if err != nil { + return errors.Wrap(err, "preparing execution context failed") + } + err = rootCmd.Execute() if err != nil { ec.Telemetry.IsError = true } diff --git a/cli/commands/update-cli.go b/cli/commands/update-cli.go index 24a04b25c9f53..7650e7d56fbb2 100644 --- a/cli/commands/update-cli.go +++ b/cli/commands/update-cli.go @@ -3,28 +3,41 @@ package commands import ( "fmt" "os" + "strings" "github.com/hasura/graphql-engine/cli" "github.com/hasura/graphql-engine/cli/update" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) +const updateCLICmdUse = "update-cli" + +const updateCLICmdExample = ` # Update CLI to latest version: + hasura update-cli + + # To disable auto-update check on the CLI, set + # "show_update_notification": false + # in ~/.hasura/config.json +` + // NewUpdateCLICmd returns the update-cli command. func NewUpdateCLICmd(ec *cli.ExecutionContext) *cobra.Command { opts := &updateOptions{ EC: ec, } updateCmd := &cobra.Command{ - Use: "update-cli", + Use: updateCLICmdUse, Short: "Update the CLI to latest version", SilenceUsage: true, PreRunE: func(cmd *cobra.Command, args []string) error { return ec.Prepare() }, RunE: func(cmd *cobra.Command, args []string) error { - return opts.run() + return opts.run(false) }, + Example: updateCLICmdExample, } return updateCmd } @@ -33,14 +46,14 @@ type updateOptions struct { EC *cli.ExecutionContext } -func (o *updateOptions) run() error { +func (o *updateOptions) run(showPrompt bool) error { currentVersion := o.EC.Version.CLISemver if currentVersion == nil { return errors.Errorf("cannot update from a non-semver version: %s", o.EC.Version.GetCLIVersion()) } o.EC.Spin("Checking for update... ") - hasUpdate, latestVersion, err := update.HasUpdate(currentVersion) + hasUpdate, latestVersion, err := update.HasUpdate(currentVersion, o.EC.LastUpdateCheckFile) o.EC.Spinner.Stop() if err != nil { return errors.Wrap(err, "command: check update") @@ -51,6 +64,14 @@ func (o *updateOptions) run() error { return nil } + if showPrompt { + ok := ask2confirm(latestVersion.String(), o.EC.Logger) + if !ok { + o.EC.Logger.Info("skippig update, run 'hasura update-cli' to update manually") + return nil + } + } + o.EC.Spin(fmt.Sprintf("Updating cli to %s... ", latestVersion.String())) err = update.ApplyUpdate(latestVersion) o.EC.Spinner.Stop() @@ -64,3 +85,22 @@ func (o *updateOptions) run() error { o.EC.Logger.WithField("version", latestVersion.String()).Info("Updated to latest version") return nil } + +func ask2confirm(v string, log *logrus.Logger) bool { + var s string + + log.WithField("vesion", v).Info("A new version is available for CLI, update? (y/N)") + _, err := fmt.Scan(&s) + if err != nil { + log.Error("unable to take input, skipping update") + return false + } + + s = strings.TrimSpace(s) + s = strings.ToLower(s) + + if s == "y" || s == "yes" { + return true + } + return false +} diff --git a/cli/global_config.go b/cli/global_config.go index 344e56850bb01..ca59ac3a90395 100644 --- a/cli/global_config.go +++ b/cli/global_config.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" + "github.com/sirupsen/logrus" + "github.com/gofrs/uuid" homedir "github.com/mitchellh/go-homedir" "github.com/pkg/errors" @@ -24,9 +26,72 @@ type GlobalConfig struct { ShowUpdateNotification bool `json:"show_update_notification"` } +type rawGlobalConfig struct { + UUID *string `json:"uuid"` + EnableTelemetry *bool `json:"enable_telemetry"` + ShowUpdateNotification *bool `json:"show_update_notification"` + + logger *logrus.Logger + shoudlWrite bool +} + +func (c *rawGlobalConfig) read(filename string) error { + b, err := ioutil.ReadFile(filename) + if err != nil { + return errors.Wrap(err, "read file") + } + err = json.Unmarshal(b, c) + if err != nil { + return errors.Wrap(err, "parse file") + } + return nil +} + +func (c *rawGlobalConfig) validateKeys() error { + // check prescence of uuid, create if doesn't exist + if c.UUID == nil { + u, err := uuid.NewV4() + if err != nil { + errors.Wrap(err, "failed generating uuid") + } + uid := u.String() + c.UUID = &uid + c.shoudlWrite = true + } + + // check enabletelemetry + if c.EnableTelemetry == nil { + trueVal := true + c.EnableTelemetry = &trueVal + c.shoudlWrite = true + } + + // check showupdatenotification + if c.ShowUpdateNotification == nil { + trueVal := true + c.ShowUpdateNotification = &trueVal + c.shoudlWrite = true + } + + return nil +} + +func (c *rawGlobalConfig) write(filename string) error { + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return errors.Wrap(err, "marshal file") + } + err = ioutil.WriteFile(filename, b, 0644) + if err != nil { + return errors.Wrap(err, "write file") + } + return nil +} + // setupGlobConfig ensures that global config directory and file exists and // reads it into the GlobalConfig object. func (ec *ExecutionContext) setupGlobalConfig() error { + // check if the directory name is set, else default if len(ec.GlobalConfigDir) == 0 { ec.Logger.Debug("global config directory is not pre-set, defaulting") home, err := homedir.Dir() @@ -37,72 +102,74 @@ func (ec *ExecutionContext) setupGlobalConfig() error { ec.GlobalConfigDir = globalConfigDir ec.Logger.Debugf("global config directory set as '%s'", ec.GlobalConfigDir) } + + // create the config directory err := os.MkdirAll(ec.GlobalConfigDir, os.ModePerm) if err != nil { return errors.Wrap(err, "cannot create global config directory") } + + // check if the filename is set, else default if len(ec.GlobalConfigFile) == 0 { ec.GlobalConfigFile = filepath.Join(ec.GlobalConfigDir, GLOBAL_CONFIG_FILE_NAME) ec.Logger.Debugf("global config file set as '%s'", ec.GlobalConfigFile) } + + // check if the global config file exist _, err = os.Stat(ec.GlobalConfigFile) if os.IsNotExist(err) { + // file does not exist, teat as first run and create it ec.Logger.Debug("global config file does not exist, this could be the first run, creating it...") - u, err := uuid.NewV4() - if err != nil { - return errors.Wrap(err, "failed to generate uuid") - } - gc := GlobalConfig{ - UUID: u.String(), - EnableTelemetry: true, - ShowUpdateNotification: true, - } - data, err := json.MarshalIndent(gc, "", " ") + + // create an empty config object + gc := &rawGlobalConfig{} + + // populate the keys + err := gc.validateKeys() if err != nil { - return errors.Wrap(err, "cannot marshal json for config file") + return errors.Wrap(err, "setup global config object") } - err = ioutil.WriteFile(ec.GlobalConfigFile, data, 0644) + + // write the file + err = gc.write(ec.GlobalConfigFile) if err != nil { - return errors.Wrap(err, "writing global config file failed") + return errors.Wrap(err, "write global config file") } - ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, string(data)) + ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, gc) + // also show a notice about telemetry ec.Logger.Info(StrTelemetryNotice) + } else if os.IsExist(err) || err == nil { + // file exists, verify contents ec.Logger.Debug("global config file exisits, verifying contents") - data, err := ioutil.ReadFile(ec.GlobalConfigFile) + + // initialize the config object + gc := rawGlobalConfig{} + err := gc.read(ec.GlobalConfigFile) if err != nil { return errors.Wrap(err, "reading global config file failed") } - var gc GlobalConfig - err = json.Unmarshal(data, &gc) + + // validate keys + err = gc.validateKeys() if err != nil { - return errors.Wrap(err, "global config file not a valid json") + return errors.Wrap(err, "validating global config file failed") } - _, err = uuid.FromString(gc.UUID) - if err != nil { - ec.Logger.Debugf("invalid uuid '%s' in global config: %v", gc.UUID, err) - // create a new UUID - ec.Logger.Debug("global config file exists, but uuid is invalid, creating a new one...") - u, err := uuid.NewV4() - if err != nil { - return errors.Wrap(err, "failed to generate uuid") - } - gc.UUID = u.String() - data, err := json.Marshal(gc) - if err != nil { - return errors.Wrap(err, "cannot marshal json for config file") - } - err = ioutil.WriteFile(ec.GlobalConfigFile, data, 0644) + + // write the file if there are any changes + if gc.shoudlWrite { + err := gc.write(ec.GlobalConfigFile) if err != nil { return errors.Wrap(err, "writing global config file failed") } - ec.Logger.Debugf("global config file written at '%s' with content '%v'", ec.GlobalConfigFile, string(data)) + ec.Logger.Debugf("global config file written at '%s' with content '%+#v'", ec.GlobalConfigFile, gc) } + } - return nil + return ec.readGlobalConfig() } // readGlobalConfig reads the configuration from global config file env vars, @@ -116,19 +183,22 @@ func (ec *ExecutionContext) readGlobalConfig() error { v.AddConfigPath(ec.GlobalConfigDir) err := v.ReadInConfig() if err != nil { - return errors.Wrap(err, "cannor read global config from file/env") + return errors.Wrap(err, "cannot read global config from file/env") } if ec.GlobalConfig == nil { ec.Logger.Debugf("global config is not pre-set, reading from current env") ec.GlobalConfig = &GlobalConfig{ - UUID: v.GetString("uuid"), - EnableTelemetry: v.GetBool("enable_telemetry"), + UUID: v.GetString("uuid"), + EnableTelemetry: v.GetBool("enable_telemetry"), + ShowUpdateNotification: v.GetBool("show_update_notification"), } } else { ec.Logger.Debugf("global config is pre-set to %#v", ec.GlobalConfig) } ec.Logger.Debugf("global config: uuid: %v", ec.GlobalConfig.UUID) ec.Logger.Debugf("global config: enableTelemetry: %v", ec.GlobalConfig.EnableTelemetry) + ec.Logger.Debugf("global config: showUpdateNotification: %v", ec.GlobalConfig.ShowUpdateNotification) + // set if telemetry can be beamed or not ec.Telemetry.CanBeam = ec.GlobalConfig.EnableTelemetry ec.Telemetry.UUID = ec.GlobalConfig.UUID diff --git a/cli/update/auto_update.go b/cli/update/auto_update.go index 7a7e4d4731dd7..d19aae23af24c 100644 --- a/cli/update/auto_update.go +++ b/cli/update/auto_update.go @@ -1 +1,40 @@ package update + +import ( + "io/ioutil" + "time" + + "github.com/pkg/errors" +) + +const ( + autoCheckInterval = 2 * time.Hour + timeLayout = time.RFC1123 +) + +func getTimeFromFileIfExists(path string) time.Time { + lastUpdateCheckTime, err := ioutil.ReadFile(path) + if err != nil { + return time.Time{} + } + timeInFile, err := time.Parse(timeLayout, string(lastUpdateCheckTime)) + if err != nil { + return time.Time{} + } + return timeInFile +} + +func writeTimeToFile(path string, inputTime time.Time) error { + err := ioutil.WriteFile(path, []byte(inputTime.Format(timeLayout)), 0644) + if err != nil { + return errors.Wrap(err, "failed writing current time to file") + } + return nil +} + +// ShouldRunCheck checks the file f for a timestamp and returns true +// if the last update check was >= autoCheckInterval . +func ShouldRunCheck(f string) bool { + lastUpdateTime := getTimeFromFileIfExists(f) + return time.Since(lastUpdateTime) >= autoCheckInterval +} diff --git a/cli/update/update.go b/cli/update/update.go index 2006e8c3e70fd..f047a955216bf 100644 --- a/cli/update/update.go +++ b/cli/update/update.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime" + "time" "github.com/kardianos/osext" @@ -88,7 +89,11 @@ func downloadAsset(url, fileName, filePath string) (*os.File, error) { } // HasUpdate tells us if there is a new update available. -func HasUpdate(currentVersion *semver.Version) (bool, *semver.Version, error) { +func HasUpdate(currentVersion *semver.Version, timeFile string) (bool, *semver.Version, error) { + if timeFile != "" { + defer writeTimeToFile(timeFile, time.Now().UTC()) + } + latestVersion, err := getLatestVersion() if err != nil { return false, nil, errors.Wrap(err, "get latest version") @@ -102,7 +107,7 @@ func HasUpdate(currentVersion *semver.Version) (bool, *semver.Version, error) { return c.Check(latestVersion), latestVersion, nil } -// ApplyUpdate downloads and applies the update. +// ApplyUpdate downloads and applies the update indicated by version v. func ApplyUpdate(v *semver.Version) error { // get the current executable exe, err := osext.Executable() From e70918634d18fea4f939e682789e5e2888636b6a Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Sat, 2 Feb 2019 14:48:23 +0530 Subject: [PATCH 09/10] execute prepare before validate in tests --- cli/cli_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/cli_test.go b/cli/cli_test.go index f3af7c025ff88..6c18269582668 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -66,6 +66,10 @@ func TestValidate(t *testing.T) { if err != nil { t.Fatalf("execution failed: %v", err) } + err = ec.Prepare() + if err != nil { + t.Fatalf("prepare failed: %v", err) + } err = ec.Validate() if err != nil { t.Fatalf("validate failed: %v", err) From aa619167c6855740911b7a2c15d7b78b8084d4b6 Mon Sep 17 00:00:00 2001 From: Shahidh K Muhammed Date: Mon, 4 Feb 2019 16:11:55 +0530 Subject: [PATCH 10/10] add v prefix --- cli/commands/update-cli.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/commands/update-cli.go b/cli/commands/update-cli.go index 7650e7d56fbb2..98c700d94cf0e 100644 --- a/cli/commands/update-cli.go +++ b/cli/commands/update-cli.go @@ -72,7 +72,7 @@ func (o *updateOptions) run(showPrompt bool) error { } } - o.EC.Spin(fmt.Sprintf("Updating cli to %s... ", latestVersion.String())) + o.EC.Spin(fmt.Sprintf("Updating cli to v%s... ", latestVersion.String())) err = update.ApplyUpdate(latestVersion) o.EC.Spinner.Stop() if err != nil { @@ -82,14 +82,14 @@ func (o *updateOptions) run(showPrompt bool) error { return errors.Wrap(err, "apply update") } - o.EC.Logger.WithField("version", latestVersion.String()).Info("Updated to latest version") + o.EC.Logger.WithField("version", "v"+latestVersion.String()).Info("Updated to latest version") return nil } func ask2confirm(v string, log *logrus.Logger) bool { var s string - log.WithField("vesion", v).Info("A new version is available for CLI, update? (y/N)") + log.Infof("A new version (v%s) is available for CLI, update? (y/N)", v) _, err := fmt.Scan(&s) if err != nil { log.Error("unable to take input, skipping update")