diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7fd704..1150f15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,6 @@ jobs: - name: Build Go binary run: go build ./cmd/runtipi/main.go + + - name: Run Go tests + run: go test ./... diff --git a/cmd/runtipi/main.go b/cmd/runtipi/main.go index 73eb9e6..3903c29 100755 --- a/cmd/runtipi/main.go +++ b/cmd/runtipi/main.go @@ -290,6 +290,16 @@ func main() { }, } + appAvailableUpdatesCmd := &cobra.Command{ + Use: "available-updates", + Short: "List apps with available updates", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + appArgs.Command = types.AppCommandAvailableUpdates + commands.RunApp(appArgs) + }, + } + appCmd.AddCommand(appStartCmd) appCmd.AddCommand(appStopCmd) appCmd.AddCommand(appUninstallCmd) @@ -301,6 +311,7 @@ func main() { appCmd.AddCommand(appDeleteBackupCmd) appCmd.AddCommand(appStartAllCmd) appCmd.AddCommand(appStopAllCmd) + appCmd.AddCommand(appAvailableUpdatesCmd) // AppStore command and subcommands appStoreCmd := &cobra.Command{ diff --git a/internal/commands/app.go b/internal/commands/app.go index d8bf527..f9e83a0 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "strings" "time" "github.com/olekukonko/tablewriter" @@ -20,6 +21,80 @@ type AppResponseBody struct { RequestID string `json:"requestId"` } +type InstalledAppsResponse struct { + // A pointer lets us distinguish an empty installed-app list from a + // missing or null field in the API response. + Installed *[]InstalledApp `json:"installed"` +} + +type InstalledApp struct { + Info AppInfo `json:"info"` + App AppDetails `json:"app"` + Metadata AppMetadata `json:"metadata"` +} + +type AppInfo struct { + URN *string `json:"urn"` + Version *string `json:"version"` +} + +type AppDetails struct { + Version *int `json:"version"` +} + +type AppMetadata struct { + LatestVersion *int `json:"latestVersion"` + LatestDockerVersion *string `json:"latestDockerVersion"` +} + +// AppUpdate describes an app with a newer Tipi version available. +type AppUpdate struct { + URN string + CurrentVersion string + LatestVersion string + TipiVersion int + LatestTipi int +} + +// FindAvailableUpdates validates the installed-app response and returns apps +// whose Tipi version is behind the latest available version. +func FindAvailableUpdates(response InstalledAppsResponse) ([]AppUpdate, error) { + if response.Installed == nil { + return nil, fmt.Errorf("unexpected response format: 'installed' field is missing or null") + } + + updates := make([]AppUpdate, 0) + for index, app := range *response.Installed { + if app.Info.URN == nil || strings.TrimSpace(*app.Info.URN) == "" { + return nil, fmt.Errorf("unexpected response format: installed[%d].info.urn is missing or empty", index) + } + if app.Info.Version == nil || strings.TrimSpace(*app.Info.Version) == "" { + return nil, fmt.Errorf("unexpected response format: installed[%d].info.version is missing or empty", index) + } + if app.App.Version == nil { + return nil, fmt.Errorf("unexpected response format: installed[%d].app.version is missing", index) + } + if app.Metadata.LatestVersion == nil { + return nil, fmt.Errorf("unexpected response format: installed[%d].metadata.latestVersion is missing", index) + } + if app.Metadata.LatestDockerVersion == nil || strings.TrimSpace(*app.Metadata.LatestDockerVersion) == "" { + return nil, fmt.Errorf("unexpected response format: installed[%d].metadata.latestDockerVersion is missing or empty", index) + } + + if *app.App.Version < *app.Metadata.LatestVersion { + updates = append(updates, AppUpdate{ + URN: *app.Info.URN, + CurrentVersion: *app.Info.Version, + LatestVersion: *app.Metadata.LatestDockerVersion, + TipiVersion: *app.App.Version, + LatestTipi: *app.Metadata.LatestVersion, + }) + } + } + + return updates, nil +} + func handleAPIResponse(spin *components.Spinner, resp *http.Response, err error, successMessage, errorMessage string) { if err != nil { spin.Fail(errorMessage) @@ -89,6 +164,67 @@ func handleSimpleAPIResponse(spin *components.Spinner, resp *http.Response, err spin.Finish() } +func handleAvailableUpdates() { + spin := components.NewSpinner("Checking for available updates...") + installedURL := utils.GetAPIBaseURL("apps/installed") + resp, err := utils.APIRequest(installedURL, "GET", "") + + if err != nil { + spin.Fail("Failed to check for available updates.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Error code: %d\n", resp.StatusCode) + fmt.Printf("Response: %s\n", string(body)) + spin.Fail("Failed to check for available updates.") + spin.Finish() + return + } + + var response InstalledAppsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + spin.Fail("Failed to parse API response.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return + } + + updates, err := FindAvailableUpdates(response) + if err != nil { + spin.Fail("Failed to validate API response.") + fmt.Printf("Error: %v\n", err) + spin.Finish() + return + } + + if len(updates) == 0 { + spin.Succeed("All apps are up to date.") + spin.Finish() + return + } + + spin.Succeed(fmt.Sprintf("Found %d app(s) with available updates.", len(updates))) + spin.Finish() + + table := tablewriter.NewWriter(os.Stdout) + table.Header([]string{"App", "Docker Version", "Latest Docker", "Tipi Version", "Latest Tipi"}) + for _, u := range updates { + table.Append([]string{ + u.URN, + u.CurrentVersion, + u.LatestVersion, + fmt.Sprintf("%d", u.TipiVersion), + fmt.Sprintf("%d", u.LatestTipi), + }) + } + table.Render() +} + func RunApp(args types.AppArgs) { lifecycleURL := utils.GetAPIBaseURL("app-lifecycle") backupsURL := utils.GetAPIBaseURL("backups") @@ -177,6 +313,9 @@ func RunApp(args types.AppArgs) { errorMessage := fmt.Sprintf("Failed to update app %s. See logs/error.log for more details.", args.ID) handleAPIResponse(spin, resp, err, "App updated successfully!", errorMessage) + case types.AppCommandAvailableUpdates: + handleAvailableUpdates() + case types.AppCommandStartAll: spin := components.NewSpinner("Starting all apps...") url := fmt.Sprintf("%s/start-all", lifecycleURL) diff --git a/internal/types/args.go b/internal/types/args.go index 62dec64..de49364 100644 --- a/internal/types/args.go +++ b/internal/types/args.go @@ -21,17 +21,18 @@ type AppArgs struct { type AppCommand string const ( - AppCommandStart AppCommand = "start" - AppCommandStop AppCommand = "stop" - AppCommandUninstall AppCommand = "uninstall" - AppCommandReset AppCommand = "reset" - AppCommandUpdate AppCommand = "update" - AppCommandBackup AppCommand = "backup" - AppCommandRestore AppCommand = "restore" - AppCommandListBackups AppCommand = "list-backups" - AppCommandDeleteBackup AppCommand = "delete-backup" - AppCommandStartAll AppCommand = "start-all" - AppCommandStopAll AppCommand = "stop-all" + AppCommandStart AppCommand = "start" + AppCommandStop AppCommand = "stop" + AppCommandUninstall AppCommand = "uninstall" + AppCommandReset AppCommand = "reset" + AppCommandUpdate AppCommand = "update" + AppCommandBackup AppCommand = "backup" + AppCommandRestore AppCommand = "restore" + AppCommandListBackups AppCommand = "list-backups" + AppCommandDeleteBackup AppCommand = "delete-backup" + AppCommandStartAll AppCommand = "start-all" + AppCommandStopAll AppCommand = "stop-all" + AppCommandAvailableUpdates AppCommand = "available-updates" ) type AppStoreArgs struct { diff --git a/tests/commands/app_test.go b/tests/commands/app_test.go new file mode 100644 index 0000000..e85b6a8 --- /dev/null +++ b/tests/commands/app_test.go @@ -0,0 +1,72 @@ +package commands_test + +import ( + "encoding/json" + "testing" + + "github.com/runtipi/cli/internal/commands" +) + +func TestFindAvailableUpdates(t *testing.T) { + tests := []struct { + name string + payload string + wantUpdates int + wantError bool + wantTipi int + wantLatestTipi int + }{ + { + name: "finds an update", + payload: `{"installed":[{"info":{"urn":"bitcoin","version":"1.0.0"},"app":{"version":1},"metadata":{"latestVersion":2,"latestDockerVersion":"2.0.0"}}]}`, + wantUpdates: 1, + wantTipi: 1, + wantLatestTipi: 2, + }, + { + name: "does not report an up to date app", + payload: `{"installed":[{"info":{"urn":"bitcoin","version":"2.0.0"},"app":{"version":2},"metadata":{"latestVersion":2,"latestDockerVersion":"2.0.0"}}]}`, + wantUpdates: 0, + }, + { + name: "rejects a missing installed field", + payload: `{}`, + wantError: true, + }, + { + name: "rejects a null installed field", + payload: `{"installed":null}`, + wantError: true, + }, + { + name: "rejects an incomplete app", + payload: `{"installed":[{"info":{"urn":"bitcoin"},"app":{"version":1},"metadata":{"latestVersion":2,"latestDockerVersion":"2.0.0"}}]}`, + wantError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var response commands.InstalledAppsResponse + if err := json.Unmarshal([]byte(test.payload), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + updates, err := commands.FindAvailableUpdates(response) + if (err != nil) != test.wantError { + t.Fatalf("FindAvailableUpdates() error = %v, want error: %v", err, test.wantError) + } + if err != nil { + return + } + if len(updates) != test.wantUpdates { + t.Fatalf("FindAvailableUpdates() returned %d updates, want %d", len(updates), test.wantUpdates) + } + if len(updates) == 1 { + if updates[0].TipiVersion != test.wantTipi || updates[0].LatestTipi != test.wantLatestTipi { + t.Fatalf("FindAvailableUpdates() returned Tipi versions %d -> %d, want %d -> %d", updates[0].TipiVersion, updates[0].LatestTipi, test.wantTipi, test.wantLatestTipi) + } + } + }) + } +}