Skip to content

Commit

Permalink
feat(cli): numerous status command for displaying app workload status
Browse files Browse the repository at this point in the history
  • Loading branch information
jfeo committed Dec 11, 2024
1 parent 98cac5a commit 8d3fd5a
Show file tree
Hide file tree
Showing 9 changed files with 571 additions and 4 deletions.
2 changes: 2 additions & 0 deletions cmd/root/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"numerous.com/cli/cmd/logs"
"numerous.com/cli/cmd/organization"
"numerous.com/cli/cmd/output"
"numerous.com/cli/cmd/status"
"numerous.com/cli/cmd/token"
cmdversion "numerous.com/cli/cmd/version"
"numerous.com/cli/internal/logging"
Expand Down Expand Up @@ -76,6 +77,7 @@ func init() {
cmdversion.Cmd,
app.Cmd,
config.Cmd,
status.Cmd,

// dummy commands to display helpful messages for legacy commands
dummyLegacyCmd("push"),
Expand Down
1 change: 1 addition & 0 deletions cmd/root/prerun.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool {
"numerous app list",
"numerous app share",
"numerous app unshare",
"numerous status",
}

for _, cmd := range commandsWithAuthRequired {
Expand Down
51 changes: 51 additions & 0 deletions cmd/status/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package status

import (
"fmt"
"net/http"

"github.com/spf13/cobra"
"numerous.com/cli/cmd/args"
"numerous.com/cli/cmd/errorhandling"
"numerous.com/cli/cmd/usage"
"numerous.com/cli/internal/app"
"numerous.com/cli/internal/gql"
)

const cmdActionText = "to see the status of"

const longFormat = `Get an overview of the status of all workloads related to an app.
%s
%s
`

var long = fmt.Sprintf(longFormat, usage.AppIdentifier(cmdActionText), usage.AppDirectoryArgument)

var cmdArgs struct {
orgSlug string
appSlug string
appDir string
}

var Cmd = &cobra.Command{
Use: "status",
Short: "Get the status of an apps workloads",
Long: long,
Args: args.OptionalAppDir(&cmdArgs.appDir),
RunE: run,
}

func run(cmd *cobra.Command, args []string) error {
service := app.New(gql.NewClient(), nil, http.DefaultClient)

input := statusInput{
appDir: cmdArgs.appDir,
appSlug: cmdArgs.appSlug,
orgSlug: cmdArgs.orgSlug,
}
err := status(cmd.Context(), service, input)

return errorhandling.ErrorAlreadyPrinted(err)
}
24 changes: 24 additions & 0 deletions cmd/status/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package status

import (
"context"

"github.com/stretchr/testify/mock"
"numerous.com/cli/internal/app"
)

var _ appReaderWorkloadLister = &mockAppReaderWorkloadLister{}

type mockAppReaderWorkloadLister struct{ mock.Mock }

// ListAppWorkloads implements appReaderWorkloadLister.
func (m *mockAppReaderWorkloadLister) ListAppWorkloads(ctx context.Context, input app.ListAppWorkloadsInput) ([]app.AppWorkload, error) {
args := m.Called(ctx, input)
return args.Get(0).([]app.AppWorkload), args.Error(1)
}

// ReadApp implements appReaderWorkloadLister.
func (m *mockAppReaderWorkloadLister) ReadApp(ctx context.Context, input app.ReadAppInput) (app.ReadAppOutput, error) {
args := m.Called(ctx, input)
return args.Get(0).(app.ReadAppOutput), args.Error(1)
}
109 changes: 109 additions & 0 deletions cmd/status/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package status

import (
"context"
"fmt"
"math"
"time"

"numerous.com/cli/internal/app"
"numerous.com/cli/internal/appident"
)

type statusInput struct {
appDir string
appSlug string
orgSlug string
}

type appReaderWorkloadLister interface {
ReadApp(ctx context.Context, input app.ReadAppInput) (app.ReadAppOutput, error)
ListAppWorkloads(ctx context.Context, input app.ListAppWorkloadsInput) ([]app.AppWorkload, error)
}

func status(ctx context.Context, apps appReaderWorkloadLister, input statusInput) error {
ai, err := appident.GetAppIdentifier(input.appDir, nil, input.orgSlug, input.appSlug)
if err != nil {
appident.PrintGetAppIdentifierError(err, input.appDir, ai)
return err
}

readOutput, err := apps.ReadApp(ctx, app.ReadAppInput{OrganizationSlug: ai.OrganizationSlug, AppSlug: ai.AppSlug})
if err != nil {
app.PrintAppError(err, ai)
return err
}

workloads, err := apps.ListAppWorkloads(ctx, app.ListAppWorkloadsInput(readOutput))
if err != nil {
app.PrintAppError(err, ai)
return err
}

printWorkloads(workloads)

return nil
}

func printWorkloads(workloads []app.AppWorkload) {
first := true
for _, w := range workloads {
if !first {
println()
}
printWorkload(w)
first = false
}
}

func printWorkload(workload app.AppWorkload) {
if workload.OrganizationSlug != "" {
fmt.Printf("Workload in %q:\n", workload.OrganizationSlug)
} else if sub := workload.Subscription; sub != nil {
fmt.Printf("Workload for subscription %q in %q:\n", sub.SubscriptionUUID, sub.OrganizationSlug)
} else {
fmt.Println("Workload of unknown origin:")
}

fmt.Printf(" Status: %s\n", workload.Status)
fmt.Printf(" Started at: %s (up for %s)\n", workload.StartedAt.Format(time.DateTime), humanDuration(time.Since(workload.StartedAt)))
}

const (
hoursPerDay float64 = 24.0
minutesPerHour float64 = 60.0
secondsPerMinute float64 = 60.0
)

func humanDuration(since time.Duration) string {
hours := since.Hours()
if hours > hoursPerDay {
fullDays := math.Floor(hours / hoursPerDay)
dayHours := math.Floor(hours - fullDays*hoursPerDay)
if dayHours > 0.0 {
return fmt.Sprintf("%d days and %d hours", int(fullDays), int(dayHours))
} else {
return fmt.Sprintf("%d days", int(fullDays))
}
}

minutes := since.Minutes()
if hours > 1.0 {
fullHours := math.Floor(hours)
hourMinutes := minutes - fullHours*minutesPerHour
if fullHours > 0.0 {
return fmt.Sprintf("%d hours and %d minutes", int(fullHours), int(hourMinutes))
}
}

seconds := since.Seconds()
if minutes > 1.0 {
fullMinutes := math.Floor(minutes)
minuteSeconds := seconds - fullMinutes*secondsPerMinute
if fullMinutes > 0.0 {
return fmt.Sprintf("%d minutes and %d seconds", int(fullMinutes), int(minuteSeconds))
}
}

return fmt.Sprintf("%d seconds", int(math.Round(seconds)))
}
121 changes: 121 additions & 0 deletions cmd/status/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package status

import (
"context"
"errors"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"numerous.com/cli/internal/app"
)

var errTest = errors.New("test error")

func TestStatus(t *testing.T) {
ctx := context.Background()
appID := "test-app-id"
appSlug := "test-app-slug"
orgSlug := "test-org-slug"
appDir := t.TempDir()
input := statusInput{appSlug: appSlug, orgSlug: orgSlug, appDir: appDir}
readAppinput := app.ReadAppInput{OrganizationSlug: orgSlug, AppSlug: appSlug}
readAppOutput := app.ReadAppOutput{AppID: appID}
listAppWorkloadsInput := app.ListAppWorkloadsInput{AppID: appID}
appWorkloads := []app.AppWorkload{
{
OrganizationSlug: "test-organization-slug",
StartedAt: time.Date(2024, time.January, 1, 13, 0, 0, 0, time.UTC),
Status: "RUNNING",
},
{
Subscription: &app.AppWorkloadSubscription{OrganizationSlug: "test-subscribing-organization-slug", SubscriptionUUID: "test-subscription-id"},
StartedAt: time.Date(2024, time.February, 2, 14, 0, 0, 0, time.UTC),
Status: "RUNNING",
},
}

t.Run("makes expected app read call", func(t *testing.T) {
mockApps := &mockAppReaderWorkloadLister{}
mockApps.On("ReadApp", mock.Anything, mock.Anything).Return(readAppOutput, nil)
mockApps.On("ListAppWorkloads", mock.Anything, mock.Anything).Return(appWorkloads, nil)

err := status(ctx, mockApps, input)

assert.NoError(t, err)
mockApps.AssertNumberOfCalls(t, "ReadApp", 1)
mockApps.AssertCalled(t, "ReadApp", ctx, readAppinput)
})

t.Run("makes expected app workload list call", func(t *testing.T) {
mockApps := &mockAppReaderWorkloadLister{}
mockApps.On("ReadApp", mock.Anything, mock.Anything).Return(readAppOutput, nil)
mockApps.On("ListAppWorkloads", mock.Anything, mock.Anything).Return(appWorkloads, nil)

err := status(ctx, mockApps, input)

assert.NoError(t, err)
mockApps.AssertNumberOfCalls(t, "ListAppWorkloads", 1)
mockApps.AssertCalled(t, "ListAppWorkloads", ctx, listAppWorkloadsInput)
})

t.Run("given app read error it is returned and app workload list is not called", func(t *testing.T) {
mockApps := &mockAppReaderWorkloadLister{}
mockApps.On("ReadApp", mock.Anything, mock.Anything).Return(app.ReadAppOutput{}, errTest)

err := status(ctx, mockApps, input)

assert.ErrorIs(t, err, errTest)
mockApps.AssertNotCalled(t, "ListAppWorkloads")
})

t.Run("given error from app workload list call it is returned", func(t *testing.T) {
mockApps := &mockAppReaderWorkloadLister{}
mockApps.On("ReadApp", mock.Anything, mock.Anything).Return(readAppOutput, nil)
mockApps.On("ListAppWorkloads", mock.Anything, mock.Anything).Return(([]app.AppWorkload)(nil), errTest)

err := status(ctx, mockApps, input)

assert.ErrorIs(t, err, errTest)
})
}

func TestHumanDuration(t *testing.T) {
type testCase struct {
name string
duration time.Duration
expected string
}

for _, tc := range []testCase{
{
name: "seconds only",
duration: 5 * time.Second,
expected: "5 seconds",
},
{
name: "seconds are rounded",
duration: 7*time.Second + 10*time.Millisecond + 20*time.Microsecond,
expected: "7 seconds",
},
{
name: "minutes and seconds",
duration: 123 * time.Second,
expected: "2 minutes and 3 seconds",
},
{
name: "hours and minutes",
duration: 123 * time.Minute,
expected: "2 hours and 3 minutes",
},
{
name: "days and hours",
duration: 50 * time.Hour,
expected: "2 days and 2 hours",
},
} {
actual := humanDuration(tc.duration)
assert.Equal(t, tc.expected, actual)
}
}
Loading

0 comments on commit 8d3fd5a

Please sign in to comment.