Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ jobs:

- name: Build Go binary
run: go build ./cmd/runtipi/main.go

- name: Run Go tests
run: go test ./...
11 changes: 11 additions & 0 deletions cmd/runtipi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{
Expand Down
139 changes: 139 additions & 0 deletions internal/commands/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"os"
"strings"
"time"

"github.com/olekukonko/tablewriter"
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 12 additions & 11 deletions internal/types/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions tests/commands/app_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}