diff --git a/cmd/edge-admin/edge-admin b/cmd/edge-admin/edge-admin new file mode 100755 index 00000000..5aa27449 Binary files /dev/null and b/cmd/edge-admin/edge-admin differ diff --git a/cmd/edge-admin/main.go b/cmd/edge-admin/main.go index 7b9f196c..783c61d0 100644 --- a/cmd/edge-admin/main.go +++ b/cmd/edge-admin/main.go @@ -7,17 +7,20 @@ import ( teaconst "github.com/TeaOSLab/EdgeAdmin/internal/const" "github.com/TeaOSLab/EdgeAdmin/internal/gen" "github.com/TeaOSLab/EdgeAdmin/internal/nodes" + "github.com/TeaOSLab/EdgeAdmin/internal/utils" _ "github.com/TeaOSLab/EdgeAdmin/internal/web" _ "github.com/iwind/TeaGo/bootstrap" "github.com/iwind/TeaGo/maps" "github.com/iwind/gosock/pkg/gosock" + "log" + "time" ) func main() { - app := apps.NewAppCmd(). + var app = apps.NewAppCmd(). Version(teaconst.Version). Product(teaconst.ProductName). - Usage(teaconst.ProcessName+" [-v|start|stop|restart|service|daemon|reset|recover|demo]"). + Usage(teaconst.ProcessName+" [-v|start|stop|restart|service|daemon|reset|recover|demo|upgrade]"). Usage(teaconst.ProcessName+" [dev|prod]"). Option("-h", "show this help"). Option("-v", "show version"). @@ -30,7 +33,8 @@ func main() { Option("recover", "enter recovery mode"). Option("demo", "switch to demo mode"). Option("dev", "switch to 'dev' mode"). - Option("prod", "switch to 'prod' mode") + Option("prod", "switch to 'prod' mode"). + Option("upgrade", "upgrade from official site") app.On("daemon", func() { nodes.NewAdminNode().Daemon() @@ -115,8 +119,42 @@ func main() { fmt.Println("switch to '" + env + "' ok") } }) + app.On("upgrade", func() { + var manager = utils.NewUpgradeManager("admin") + log.Println("checking latest version ...") + var ticker = time.NewTicker(1 * time.Second) + go func() { + var lastProgress float32 = 0 + var isStarted = false + for range ticker.C { + if manager.IsDownloading() { + if !isStarted { + log.Println("start downloading v" + manager.NewVersion() + " ...") + isStarted = true + } + var progress = manager.Progress() + if progress >= 0 { + if progress == 0 || progress == 1 || progress-lastProgress >= 0.1 { + lastProgress = progress + log.Println(fmt.Sprintf("%.2f%%", manager.Progress()*100)) + } + } + } else { + break + } + } + }() + err := manager.Start() + if err != nil { + log.Println("upgrade failed: " + err.Error()) + return + } + log.Println("finished!") + log.Println("restarting ...") + app.RunRestart() + }) app.Run(func() { - adminNode := nodes.NewAdminNode() + var adminNode = nodes.NewAdminNode() adminNode.Run() }) } diff --git a/internal/apps/app_cmd.go b/internal/apps/app_cmd.go index a7df037a..f1faea09 100644 --- a/internal/apps/app_cmd.go +++ b/internal/apps/app_cmd.go @@ -123,7 +123,7 @@ func (this *AppCmd) On(arg string, callback func()) { // Run 运行 func (this *AppCmd) Run(main func()) { // 获取参数 - args := os.Args[1:] + var args = os.Args[1:] if len(args) > 0 { switch args[0] { case "-v", "version", "-version", "--version": @@ -139,7 +139,7 @@ func (this *AppCmd) Run(main func()) { this.runStop() return case "restart": - this.runRestart() + this.RunRestart() return case "status": this.runStatus() @@ -160,7 +160,7 @@ func (this *AppCmd) Run(main func()) { } // 日志 - writer := new(LogWriter) + var writer = new(LogWriter) writer.Init() logs.SetWriter(writer) @@ -210,7 +210,7 @@ func (this *AppCmd) runStop() { } // 重启 -func (this *AppCmd) runRestart() { +func (this *AppCmd) RunRestart() { this.runStop() time.Sleep(1 * time.Second) this.runStart() diff --git a/internal/utils/upgrade_manager.go b/internal/utils/upgrade_manager.go new file mode 100644 index 00000000..25015ef3 --- /dev/null +++ b/internal/utils/upgrade_manager.go @@ -0,0 +1,292 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package utils + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + teaconst "github.com/TeaOSLab/EdgeAdmin/internal/const" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/maps" + "github.com/iwind/TeaGo/types" + stringutil "github.com/iwind/TeaGo/utils/string" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +type UpgradeFileWriter struct { + rawWriter io.Writer + written int64 +} + +func NewUpgradeFileWriter(rawWriter io.Writer) *UpgradeFileWriter { + return &UpgradeFileWriter{rawWriter: rawWriter} +} + +func (this *UpgradeFileWriter) Write(p []byte) (n int, err error) { + n, err = this.rawWriter.Write(p) + this.written += int64(n) + return +} + +func (this *UpgradeFileWriter) TotalWritten() int64 { + return this.written +} + +type UpgradeManager struct { + client *http.Client + + component string + + newVersion string + contentLength int64 + isDownloading bool + writer *UpgradeFileWriter + body io.ReadCloser + isCancelled bool +} + +func NewUpgradeManager(component string) *UpgradeManager { + return &UpgradeManager{ + component: component, + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + CheckRedirect: nil, + Jar: nil, + Timeout: 30 * time.Minute, + }, + } +} + +func (this *UpgradeManager) Start() error { + if this.isDownloading { + return errors.New("another process is running") + } + + this.isDownloading = true + + defer func() { + this.client.CloseIdleConnections() + this.isDownloading = false + }() + + // 检查unzip + unzipExe, _ := exec.LookPath("unzip") + if len(unzipExe) == 0 { + // TODO install unzip automatically or pack with a static 'unzip' file + return errors.New("can not find 'unzip' command") + } + + // 检查cp + cpExe, _ := exec.LookPath("cp") + if len(cpExe) == 0 { + return errors.New("can not find 'cp' command") + } + + // 检查新版本 + var downloadURL = "" + { + var url = teaconst.UpdatesURL + var osName = runtime.GOOS + if Tea.IsTesting() && osName == "darwin" { + osName = "linux" + } + url = strings.ReplaceAll(url, "${os}", osName) + url = strings.ReplaceAll(url, "${arch}", runtime.GOARCH) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return errors.New("create url request failed: " + err.Error()) + } + + resp, err := this.client.Do(req) + if err != nil { + return errors.New("read latest version failed: " + err.Error()) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return errors.New("read latest version failed: invalid response code '" + types.String(resp.StatusCode) + "'") + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return errors.New("read latest version failed: " + err.Error()) + } + + var m = maps.Map{} + err = json.Unmarshal(data, &m) + if err != nil { + return errors.New("invalid response data: " + err.Error() + ", origin data: " + string(data)) + } + + var code = m.GetInt("code") + if code != 200 { + return errors.New(m.GetString("message")) + } + + var dataMap = m.GetMap("data") + var downloadHost = dataMap.GetString("host") + var versions = dataMap.GetSlice("versions") + var downloadPath = "" + for _, component := range versions { + var componentMap = maps.NewMap(component) + if componentMap.Has("version") { + if componentMap.GetString("code") == this.component { + var version = componentMap.GetString("version") + if stringutil.VersionCompare(version, teaconst.Version) > 0 { + this.newVersion = version + downloadPath = componentMap.GetString("url") + break + } + } + } + } + + if len(downloadPath) == 0 { + return errors.New("no latest version to download") + } + + downloadURL = downloadHost + downloadPath + } + + { + req, err := http.NewRequest(http.MethodGet, downloadURL, nil) + if err != nil { + return errors.New("create download request failed: " + err.Error()) + } + + resp, err := this.client.Do(req) + if err != nil { + return errors.New("download failed: " + downloadURL + ": " + err.Error()) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return errors.New("download failed: " + downloadURL + ": invalid response code '" + types.String(resp.StatusCode) + "'") + } + + this.contentLength = resp.ContentLength + this.body = resp.Body + + // download to tmp + var tmpDir = os.TempDir() + var filename = filepath.Base(downloadURL) + + var destFile = tmpDir + "/" + filename + _ = os.Remove(destFile) + + fp, err := os.Create(destFile) + if err != nil { + return errors.New("create file failed: " + err.Error()) + } + + defer func() { + // 删除安装文件 + _ = os.Remove(destFile) + }() + + this.writer = NewUpgradeFileWriter(fp) + + _, err = io.Copy(this.writer, resp.Body) + if err != nil { + _ = fp.Close() + if this.isCancelled { + return nil + } + return errors.New("download failed: " + err.Error()) + } + + _ = fp.Close() + + // unzip + var unzipDir = tmpDir + "/edge-" + this.component + "-tmp" + stat, err := os.Stat(unzipDir) + if err == nil && stat.IsDir() { + err = os.RemoveAll(unzipDir) + if err != nil { + return errors.New("remove old dir '" + unzipDir + "' failed: " + err.Error()) + } + } + var unzipCmd = exec.Command(unzipExe, "-q", "-o", destFile, "-d", unzipDir) + var unzipStderr = &bytes.Buffer{} + unzipCmd.Stderr = unzipStderr + err = unzipCmd.Run() + if err != nil { + return errors.New("unzip installation file failed: " + err.Error() + ": " + unzipStderr.String()) + } + + installationFiles, err := filepath.Glob(unzipDir + "/edge-" + this.component + "/*") + if err != nil { + return errors.New("lookup installation files failed: " + err.Error()) + } + + // cp to target dir + currentExe, err := os.Executable() + if err != nil { + return errors.New("reveal current executable file path failed: " + err.Error()) + } + var targetDir = filepath.Dir(filepath.Dir(currentExe)) + if !Tea.IsTesting() { + for _, installationFile := range installationFiles { + var cpCmd = exec.Command(cpExe, "-R", "-f", installationFile, targetDir) + var cpStderr = &bytes.Buffer{} + cpCmd.Stderr = cpStderr + err = cpCmd.Run() + if err != nil { + return errors.New("overwrite installation files failed: '" + cpCmd.String() + "': " + cpStderr.String()) + } + } + } + + // remove tmp + _ = os.RemoveAll(unzipDir) + } + + return nil +} + +func (this *UpgradeManager) IsDownloading() bool { + return this.isDownloading +} + +func (this *UpgradeManager) Progress() float32 { + if this.contentLength <= 0 { + return -1 + } + if this.writer == nil { + return -1 + } + return float32(this.writer.TotalWritten()) / float32(this.contentLength) +} + +func (this *UpgradeManager) NewVersion() string { + return this.newVersion +} + +func (this *UpgradeManager) Cancel() error { + this.isCancelled = true + this.isDownloading = false + + if this.body != nil { + _ = this.body.Close() + } + return nil +} diff --git a/internal/utils/upgrade_manager_test.go b/internal/utils/upgrade_manager_test.go new file mode 100644 index 00000000..a967530a --- /dev/null +++ b/internal/utils/upgrade_manager_test.go @@ -0,0 +1,35 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package utils_test + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/utils" + "testing" + "time" +) + +func TestNewUpgradeManager(t *testing.T) { + var manager = utils.NewUpgradeManager("admin") + + var ticker = time.NewTicker(2 * time.Second) + go func() { + for range ticker.C { + if manager.IsDownloading() { + t.Logf("%.2f%%", manager.Progress()*100) + } + } + }() + + /**go func() { + time.Sleep(5 * time.Second) + if manager.IsDownloading() { + t.Log("cancel downloading") + _ = manager.Cancel() + } + }()**/ + + err := manager.Start() + if err != nil { + t.Fatal(err) + } +}