diff --git a/.gitignore b/.gitignore index 0ba6f68..66733e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # CLI Build /runtipi-cli-go +/runtipi-cli-go.bak # CLI Created Files /apps diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..4b0a584 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path" + + "github.com/Delta456/box-cli-maker" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveiliop56/runtipi-cli-go/internal/env" + "github.com/steveiliop56/runtipi-cli-go/internal/release" + "github.com/steveiliop56/runtipi-cli-go/internal/spinner" + "github.com/steveiliop56/runtipi-cli-go/internal/utils" +) + +func init() { + updateCmd.Flags().BoolVar(&noPermissions, "no-permissions", false, "Skip setting permissions.") + updateCmd.Flags().StringVar(&envFile, "env-file", "", "Path to custom .env file") + rootCmd.AddCommand(updateCmd) +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update to the latest version", + Long: "Use this command to update your runtipi instance to the latest version", + Run: func(cmd *cobra.Command, args []string) { + // Checks args + if len(args) == 0 { + utils.PrintError("Please provide a version to update too, you can use latest, nightly or a specific tag") + os.Exit(1) + } + + // Define colors + blue := color.New(color.FgBlue).SprintFunc() + + // Root folder + rootFolder, osErr := os.Getwd() + + if osErr != nil { + utils.PrintError("Faild to get root folder") + fmt.Printf("Error: %s\n", osErr) + os.Exit(1) + } + + // Define paths + cliPath := path.Join(rootFolder, "runtipi-cli-go") + + // Start spinner + spinner.SetMessage("Updating runtipi...") + spinner.Start() + + // Get versions + version := args[0] + currentVersion, currentVersionErr := env.GetEnvValue("TIPI_VERSION") + if currentVersionErr != nil { + utils.PrintError("Failed to get current environment version") + fmt.Printf("Error: %s\n", currentVersionErr) + os.Exit(1) + } + + spinner.PrintUpdate("Updating from " + blue(currentVersion) + " to " + blue(version)) + + // Validate + spinner.SetMessage("Validating version") + + isValid, validateErr := release.ValidateVersion(version) + + if validateErr != nil { + spinner.Fail("Error in validating version") + spinner.Stop() + fmt.Printf("Error: %s\n", validateErr) + os.Exit(1) + } + + if !isValid { + spinner.Fail("Version is not valid") + spinner.Stop() + os.Exit(1) + } + + spinner.Succeed("Version is valid") + + // Compare versions + spinner.SetMessage("Comparing versions...") + + versionToUpdate := "" + + if version == "latest" { + latestVersion, latestVersionErr := release.GetLatestVersion() + if latestVersionErr != nil { + spinner.Fail("Failed to get latest version") + spinner.Stop() + fmt.Printf("Error: %s\n", latestVersionErr) + os.Exit(1) + } + versionToUpdate = latestVersion + } else if version == "nightly" { + spinner.Fail("Nightly currently not supported") + spinner.Stop() + os.Exit(1) + } else { + if currentVersion != "nightly" { + isMajor, isMajorErr := release.IsMajorBump(version, currentVersion) + + if isMajorErr != nil { + spinner.Fail("Failed to compare versions") + spinner.Stop() + fmt.Printf("Error: %s\n", isMajorErr) + os.Exit(1) + } + + if isMajor { + spinner.Fail("You are trying to update to a new major version. Please update manually using the update instructions on the website. https://runtipi.io/docs/reference/breaking-updates") + spinner.Stop() + os.Exit(1) + } + } + + versionToUpdate = version + } + + spinner.Succeed("Versions compared") + + // Backup CLI + spinner.SetMessage("Backing up current CLI") + + backupErr := release.BackupCurrentCLI() + + if backupErr != nil { + spinner.Fail("Failed to backup current CLI, no modification were made") + spinner.Stop() + fmt.Printf("Error: %s\n", backupErr) + os.Exit(1) + } + + spinner.Succeed("CLI backed up") + + // Download latest CLI + spinner.SetMessage("Downloading latest CLI") + + downloadErr := release.DownloadLatestCLI(versionToUpdate) + + if downloadErr != nil { + spinner.Fail("Failed to download latest CLI, please copy the runtipi-cli-go.bak file to runtipi-cli-go and try again") + spinner.Stop() + fmt.Printf("Error: %s\n", downloadErr) + os.Exit(1) + } + + spinner.Succeed("New CLI downloaded successfully") + + // Start new CLI + spinner.SetMessage("Starting new CLI") + + cliArgs := []string{"start"} + + if envFile != "" { + cliArgs = append(cliArgs, "--env-file") + cliArgs = append(cliArgs, envFile) + } + + if noPermissions { + cliArgs = append(cliArgs, "--no-permissions") + } + + _, startErr := exec.Command(cliPath, cliArgs...).Output() + + if startErr != nil { + spinner.Fail("Failed to start the new CLI, please copy the runtipi-cli-go.bak file to runtipi-cli-go and try again") + spinner.Stop() + fmt.Printf("Error: %s\n", downloadErr) + os.Exit(1) + } + + spinner.Succeed("New CLI started successfully, you are good to go") + + // Succeed + spinner.Stop() + + internalIp, _ := env.GetEnvValue("INTERNAL_IP") + nginxPort, _ := env.GetEnvValue("NGINX_PORT") + + boxMessage := "Visit http://" + internalIp + ":" + nginxPort + " to access the dashboard\n\nFind documentation and guides at: https://runtipi.io\n\nTipi is entirely written in TypeScript and we are looking for contributors!" + + Box := box.New(box.Config{Py: 2, Px: 2, Type: "Double", Color: "Green", TitlePos: "Top", ContentAlign: "Center"}) + Box.Print("Runtipi updated successfully 🎉", boxMessage) + }, +} \ No newline at end of file diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 0000000..ddcc425 --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,155 @@ +package release + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "strconv" + "strings" + + "github.com/steveiliop56/runtipi-cli-go/internal/system" +) + +type GithubRelease struct { + TagName string `json:"tag_name"` + Status string `json:"status"` +} + +func IsMajorBump(newVersion string, currentVersion string) (bool, error) { + newVersionMajor := strings.Split(strings.Replace(newVersion, "v", "", 1), ".")[0] + currentVersionMajor := strings.Split(strings.Replace(currentVersion, "v", "", 1), ".")[0] + + newVersionMajorInt, newVersionMajorIntErr := strconv.ParseInt(newVersionMajor, 10, 64) + + if newVersionMajorIntErr != nil { + return false, newVersionMajorIntErr + } + + currentVersionMajorInt, currentVersionMajorIntErr := strconv.ParseInt(currentVersionMajor, 10, 64) + + if currentVersionMajorIntErr != nil { + return false, currentVersionMajorIntErr + } + + if newVersionMajorInt > currentVersionMajorInt { + return true, nil + } + + return false, nil +} + +func GetLatestVersion() (string, error) { + apiUrl := "https://api.github.com/repos/steveiliop56/runtipi-cli-go/releases/latest" + + response, requestErr := http.Get(apiUrl) + + if requestErr != nil { + return "", requestErr + } + + defer response.Body.Close() + + release := new(GithubRelease) + + jsonErr := json.NewDecoder(response.Body).Decode(&release) + + if jsonErr != nil { + return "", jsonErr + } + + return release.TagName, nil +} + +func ValidateVersion(version string) (bool, error) { + apiUrl := "https://api.github.com/repos/steveiliop56/runtipi-cli-go/releases/tags/" + version + + response, requestErr := http.Get(apiUrl) + + if requestErr != nil { + return false, requestErr + } + + defer response.Body.Close() + + release := new(GithubRelease) + + jsonErr := json.NewDecoder(response.Body).Decode(&release) + + if jsonErr != nil { + return false, jsonErr + } + + if release.Status == "404" { + return false, nil + } + + return true, nil +} + +func DownloadLatestCLI(version string) (error) { + arch := system.GetArch() + assetUrl := fmt.Sprintf("https://github.com/steveiliop56/runtipi-cli-go/releases/download/%s/runtipi-cli-go-%s", version, arch) + + rootFolder, osErr := os.Getwd() + + if osErr != nil { + return osErr + } + + cliPath := path.Join(rootFolder, "runtipi-cli-go") + + os.Remove(cliPath) + + create, createErr := os.Create(cliPath) + + if createErr != nil { + return createErr + } + + defer create.Close() + + response, requestErr := http.Get(assetUrl) + + if requestErr != nil { + return requestErr + } + + defer response.Body.Close() + + _, writeErr := io.Copy(create, response.Body) + + if writeErr != nil { + return writeErr + } + + _, chmodErr := exec.Command("chmod", "+x", cliPath).Output() + + if chmodErr != nil { + return chmodErr + } + + return nil +} + +func BackupCurrentCLI() (error) { + rootFolder, osErr := os.Getwd() + + if osErr != nil { + return osErr + } + + cliPath := path.Join(rootFolder, "runtipi-cli-go") + cliBackupPath := path.Join(rootFolder, "runtipi-cli-go.bak") + + _, copyErr := exec.Command("cp", cliPath, cliBackupPath).Output() + + if copyErr != nil { + return copyErr + } + + return nil +} \ No newline at end of file diff --git a/internal/release/release_test.go b/internal/release/release_test.go new file mode 100644 index 0000000..5cb559c --- /dev/null +++ b/internal/release/release_test.go @@ -0,0 +1,136 @@ +package release_test + +import ( + "errors" + "os" + "path" + "testing" + + "github.com/steveiliop56/runtipi-cli-go/internal/release" +) + +func init() { + // Change root folder + os.Chdir("../..") +} + +// Test the major bump validator works +func TestMajorValidator(t *testing.T) { + // Check major + isMajor, isMajorErr := release.IsMajorBump("v2.0.0", "v1.0.0") + + // Check for errors + if isMajorErr != nil { + t.Fatalf("Major bump validator failed, error: %s\n", isMajorErr) + } + + // Check result + if !isMajor { + t.Fatalf("Major bump validator returned false on major bump!") + } + + // Run the validator again on feature version + isMajorFeat, isMajorFeatErr := release.IsMajorBump("v1.1.0", "v1.0.0") + + // Check for errors + if isMajorFeatErr != nil { + t.Fatalf("Mahor bump validator failed, error: %s\n", isMajorFeatErr) + } + + // Check result + if isMajorFeat { + t.Fatalf("Major bump validator returned true on feature!") + } +} + +// Test validate version +func TestValidateVersion(t *testing.T) { + // Try correct version + validateCheckCorrect, validateCheckCorrectErr := release.ValidateVersion("v0.1.0-alpha.1-runtipi-v3.6.0") + + // Check for errors + if validateCheckCorrectErr != nil { + t.Fatalf("Version validater returned an error: %s\n", validateCheckCorrectErr) + } + + // Check result + if !validateCheckCorrect { + t.Fatalf("Version validator returned false on correct version!") + } + + // Try wrong version + validateCheckWrong, validateCheckWrongErr := release.ValidateVersion("v0.1.0-alpha.1-runtipi-v3.5.8") + + // Check for errors + if validateCheckWrongErr != nil { + t.Fatalf("Version validater returned an error: %s\n", validateCheckWrongErr) + } + + // Check result + if validateCheckWrong { + t.Fatalf("Version validator returned true on wrongs version!") + } +} + +// Test CLI download +func TestCLIDownload(t *testing.T) { + // Get root folder + rootFolder, osErr := os.Getwd() + + if osErr != nil { + t.Fatalf("Failed to get root folder, error: %s\n", osErr) + } + + // Define paths + cliPath := path.Join(rootFolder, "runtipi-cli-go") + + // Delete old CLI + os.Remove(cliPath) + + // Download new CLI + downloadErr := release.DownloadLatestCLI("v0.1.0-alpha.1-runtipi-v3.6.0") + + // Check for errors + if downloadErr != nil { + t.Fatalf("Failed to download CLI, error: %s\n", downloadErr) + } + + // Check if CLI got downloaded + if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) { + t.Fatal("CLI doesn't exist!") + } +} + +// Test backup CLI +func TestBackupCLI(t *testing.T) { + // Get root folder + rootFolder, osErr := os.Getwd() + + if osErr != nil { + t.Fatalf("Failed to get root folder, error: %s\n", osErr) + } + + // Define paths + cliPath := path.Join(rootFolder, "runtipi-cli-go") + cliBackupPath := path.Join(rootFolder, "runtipi-cli-go") + + // Delete old files + os.Remove(cliPath) + os.Remove(cliBackupPath) + + // Create empty CLI file + os.Create(cliPath) + + // Create backup + backupErr := release.BackupCurrentCLI() + + // Check for errors + if backupErr != nil { + t.Fatalf("Error in backing up CLI, error: %s\n", backupErr) + } + + // Check if backup file exists + if _, err := os.Stat(cliBackupPath); errors.Is(err, os.ErrNotExist) { + t.Fatal("CLI backup doesn't exist!") + } +} \ No newline at end of file diff --git a/internal/spinner/spinner.go b/internal/spinner/spinner.go index 1db42a8..0fd72b8 100644 --- a/internal/spinner/spinner.go +++ b/internal/spinner/spinner.go @@ -32,4 +32,10 @@ func Fail(message string) { s.Stop() utils.PrintError(message) s.Start() +} + +func PrintUpdate(message string) { + s.Stop() + utils.PrintUpdate(message) + s.Start() } \ No newline at end of file diff --git a/internal/utils/utils.go b/internal/utils/utils.go index da72161..84ae3a5 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -18,4 +18,11 @@ func PrintSuccess(message string) { fmt.Print("✓ ") color.Unset() fmt.Println(message) +} + +func PrintUpdate(message string) { + color.Set(color.FgBlue) + fmt.Print("↑ ") + color.Unset() + fmt.Println(message) } \ No newline at end of file