From 8d3fd5a1b1c395275d4d9da70c390ec8432c7307 Mon Sep 17 00:00:00 2001 From: Jens Feodor Nielsen Date: Wed, 11 Dec 2024 13:36:31 +0100 Subject: [PATCH] feat(cli): `numerous status` command for displaying app workload status --- cmd/root/cmd.go | 2 + cmd/root/prerun.go | 1 + cmd/status/cmd.go | 51 ++++++++++ cmd/status/mock.go | 24 +++++ cmd/status/status.go | 109 +++++++++++++++++++++ cmd/status/status_test.go | 121 ++++++++++++++++++++++++ internal/app/app_workloads_list.go | 95 +++++++++++++++++++ internal/app/app_workloads_list_test.go | 118 +++++++++++++++++++++++ shared/schema.gql | 54 ++++++++++- 9 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 cmd/status/cmd.go create mode 100644 cmd/status/mock.go create mode 100644 cmd/status/status.go create mode 100644 cmd/status/status_test.go create mode 100644 internal/app/app_workloads_list.go create mode 100644 internal/app/app_workloads_list_test.go diff --git a/cmd/root/cmd.go b/cmd/root/cmd.go index f7bd45d..e75fe33 100644 --- a/cmd/root/cmd.go +++ b/cmd/root/cmd.go @@ -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" @@ -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"), diff --git a/cmd/root/prerun.go b/cmd/root/prerun.go index da772ca..2270417 100644 --- a/cmd/root/prerun.go +++ b/cmd/root/prerun.go @@ -65,6 +65,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool { "numerous app list", "numerous app share", "numerous app unshare", + "numerous status", } for _, cmd := range commandsWithAuthRequired { diff --git a/cmd/status/cmd.go b/cmd/status/cmd.go new file mode 100644 index 0000000..03a8612 --- /dev/null +++ b/cmd/status/cmd.go @@ -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) +} diff --git a/cmd/status/mock.go b/cmd/status/mock.go new file mode 100644 index 0000000..d6d6ee4 --- /dev/null +++ b/cmd/status/mock.go @@ -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) +} diff --git a/cmd/status/status.go b/cmd/status/status.go new file mode 100644 index 0000000..b6ce997 --- /dev/null +++ b/cmd/status/status.go @@ -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))) +} diff --git a/cmd/status/status_test.go b/cmd/status/status_test.go new file mode 100644 index 0000000..d9f441c --- /dev/null +++ b/cmd/status/status_test.go @@ -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) + } +} diff --git a/internal/app/app_workloads_list.go b/internal/app/app_workloads_list.go new file mode 100644 index 0000000..415a49d --- /dev/null +++ b/internal/app/app_workloads_list.go @@ -0,0 +1,95 @@ +package app + +import ( + "context" + "time" + + "github.com/hasura/go-graphql-client" +) + +type AppWorkloadSubscription struct { + OrganizationSlug string + SubscriptionUUID string +} + +type AppWorkload struct { + OrganizationSlug string + Subscription *AppWorkloadSubscription + StartedAt time.Time + Status string +} + +type ListAppWorkloadsInput struct { + AppID string +} + +type appWorkloadsResponseWorkload struct { + Status string + Organization *struct { + Slug string + } + Subscription *struct { + ID string + InboundOrganization struct { + Slug string + } + } + StartedAt time.Time +} + +type appWorkloadsResponse struct { + AppWorkloads []appWorkloadsResponseWorkload `graphql:"appWorkloads(appID: $appID)"` +} + +const queryAppWorkloadsText = ` +query CLIListAppWorkloads($appID: ID!) { + appWorkloads(appID: $appID) { + status + startedAt + organization { + slug + } + subscription { + id + inboundOrganization { + slug + } + } + } +} +` + +func (s *Service) ListAppWorkloads(ctx context.Context, input ListAppWorkloadsInput) ([]AppWorkload, error) { + var resp appWorkloadsResponse + variables := map[string]any{"appID": input.AppID} + + err := s.client.Exec(ctx, queryAppWorkloadsText, &resp, variables, graphql.OperationName("CLIListAppWorkloads")) + if err != nil { + return nil, convertErrors(err) + } + + workloads := []AppWorkload{} + for _, wl := range resp.AppWorkloads { + workloads = append(workloads, appWorkloadFromResponse(wl)) + } + + return workloads, nil +} + +func appWorkloadFromResponse(responseWorkload appWorkloadsResponseWorkload) AppWorkload { + wl := AppWorkload{ + StartedAt: responseWorkload.StartedAt, + Status: responseWorkload.Status, + } + + if responseWorkload.Organization != nil { + wl.OrganizationSlug = responseWorkload.Organization.Slug + } else if responseWorkload.Subscription != nil { + wl.Subscription = &AppWorkloadSubscription{ + OrganizationSlug: responseWorkload.Subscription.InboundOrganization.Slug, + SubscriptionUUID: responseWorkload.Subscription.ID, + } + } + + return wl +} diff --git a/internal/app/app_workloads_list_test.go b/internal/app/app_workloads_list_test.go new file mode 100644 index 0000000..e070dc6 --- /dev/null +++ b/internal/app/app_workloads_list_test.go @@ -0,0 +1,118 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "numerous.com/cli/internal/test" +) + +func TestAppWorkloadsList(t *testing.T) { + t.Run("given successful response it returns expected app workloads", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + appWorkloadsRespBody := ` + { + "data": { + "appWorkloads": [ + { + "organization": { + "slug": "test-organization-slug" + }, + "startedAt": "2024-01-01T13:00:00.000Z", + "status": "RUNNING" + }, + { + "subscription": { + "id": "test-subscription-id", + "inboundOrganization": { + "slug": "test-subscribing-organization-slug" + } + }, + "startedAt": "2024-02-02T14:00:00.000Z", + "status": "RUNNING" + } + ] + } + } + ` + resp := test.JSONResponse(appWorkloadsRespBody) + doer.On("Do", mock.Anything).Return(resp, nil).Once() + + input := ListAppWorkloadsInput{AppID: "test-app-id"} + actual, err := s.ListAppWorkloads(context.TODO(), input) + + expected := []AppWorkload{ + { + OrganizationSlug: "test-organization-slug", + Subscription: nil, + StartedAt: time.Date(2024, time.January, 1, 13, 0, 0, 0, time.UTC), + Status: "RUNNING", + }, + { + OrganizationSlug: "", + Subscription: &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", + }, + } + assert.NoError(t, err) + assert.Equal(t, expected, actual) + }) + + t.Run("given graphql error it returns expected error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + appWorkloadsRespBody := ` + { + "errors": [{ + "message": "test error message", + "location": [{"line": 1, "column": 1}], + "path": ["appWorkloads"] + }] + } + ` + resp := test.JSONResponse(appWorkloadsRespBody) + doer.On("Do", mock.Anything).Return(resp, nil).Once() + + input := ListAppWorkloadsInput{AppID: "test-app-id"} + actual, err := s.ListAppWorkloads(context.TODO(), input) + + assert.Nil(t, actual) + assert.ErrorContains(t, err, "test error message") + }) + + t.Run("given access denied error then it returns access denied error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + appWorkloadsRespBody := ` + { + "errors": [{ + "message": "access denied", + "location": [{"line": 1, "column": 1}], + "path": ["appWorkloads"] + }] + } + ` + resp := test.JSONResponse(appWorkloadsRespBody) + doer.On("Do", mock.Anything).Return(resp, nil).Once() + + input := ListAppWorkloadsInput{AppID: "test-app-id"} + actual, err := s.ListAppWorkloads(context.TODO(), input) + + assert.Nil(t, actual) + assert.ErrorIs(t, err, ErrAccessDenied) + }) +} diff --git a/shared/schema.gql b/shared/schema.gql index 3d5ccbe..15fac40 100644 --- a/shared/schema.gql +++ b/shared/schema.gql @@ -7,6 +7,7 @@ directive @trackAppOperation(eventName: String!) on FIELD_DEFINITION directive @trackAppUseOperation on FIELD_DEFINITION directive @trackToolOperation(eventName: String!) on FIELD_DEFINITION directive @trackToolUseOperation on FIELD_DEFINITION +directive @trackSharedAppUseOperation on FIELD_DEFINITION directive @trackSubscriptionOperation(eventName: String!) on FIELD_DEFINITION directive @trackSubscriptionUseOperation on FIELD_DEFINITION directive @canAccessInvitation on FIELD_DEFINITION @@ -220,6 +221,7 @@ extend type Mutation { memberId: ID! ): OrganizationMemberRemoveResult! @hasRole(role: ADMIN) } + ############################################################################### ### End of organization management ############################################################################### @@ -238,11 +240,13 @@ type Auth0WhiteLabelInvitation { invitedAt: Time! } + extend type Mutation { Auth0WhiteLabelInvitationCreate( input: Auth0WhiteLabelInvitationInput ): Auth0WhiteLabelInvitation! } + ############################################################################### ### End of Auth0Organization management ############################################################################### @@ -397,6 +401,13 @@ type AppDeployment { sharedURL: String } +type AppDeploymentWorkload { + organization: Organization # optional, set if workload is a private organization app + subscription: AppSubscription # optional, set if workload is related to a subscription + startedAt: Time! + status: AppDeploymentStatus! +} + type AppVersionDocumentationFile { filename: String! contentBase64: String! @@ -512,6 +523,7 @@ union AppVersionCreateGitHubResult = AppVersion | GitHubRepositoryNotFound extend type Query { app(organizationSlug: String!, appSlug: String!): App @hasRole(role: USER) + appWorkloads(appID: ID!): [AppDeploymentWorkload!]! @canManageApp } extend type Mutation { @@ -540,14 +552,20 @@ extend type Mutation { appDeployHeartbeat(deployID: ID!): AppDeploymentHeartbeat! @canUseApp @trackAppUseOperation + appSharedDeployHeartbeat(sharedURLID: ID!): AppDeploymentHeartbeat! + @trackSharedAppUseOperation appVersionCreateGitHub( appID: ID! input: AppVersionCreateGitHubInput! ): AppVersionCreateGitHubResult @canManageApp @trackAppOperation(eventName: "App Version Create from GitHub") - appDeployShare(deployID: ID!): AppDeployment! @canManageApp - appDeployUnshare(deployID: ID!): AppDeployment! @canManageApp + appDeployShare(deployID: ID!): AppDeployment! + @canManageApp + @trackAppOperation(eventName: "App Share") + appDeployUnshare(deployID: ID!): AppDeployment! + @canManageApp + @trackAppOperation(eventName: "App Unshare") } extend type Subscription { @@ -675,6 +693,11 @@ union SubscriptionOfferAcceptResult = | SubscriptionOfferNotFound | SubscriptionOfferInvalidStatus +union SubscriptionOfferRejectResult = + SubscriptionOffer + | SubscriptionOfferNotFound + | SubscriptionOfferInvalidStatus + union SubscriptionOfferWithdraw = SubscriptionOffer | SubscriptionOfferNotFound @@ -713,8 +736,11 @@ extend type Mutation { ): SubscriptionOfferAcceptResult! @canAccessSubscriptionOffer @trackSubscriptionOperation(eventName: "Subscription Offer Accept") - subscriptionOfferReject(subscriptionOfferId: ID!): SubscriptionOfferResult! + subscriptionOfferReject( + subscriptionOfferId: ID! + ): SubscriptionOfferRejectResult! @canAccessSubscriptionOffer + @trackSubscriptionOperation(eventName: "Subscription Offer Reject") subscriptionOfferWithdraw( subscriptionOfferId: ID! ): SubscriptionOfferWithdraw! @@ -735,6 +761,25 @@ extend type Mutation { ### End of Subscriptions ############################################################################### +############################################################################### +### Releases +############################################################################### + +type GithubRepositoryRelease { + id: String! + name: String! + body: String! + publishedAt: Time! +} + +extend type Query { + numerousReleases: [GithubRepositoryRelease!]! +} + +############################################################################### +### Releases +############################################################################### + ############################################################################### ### Customer accounts ############################################################################### @@ -973,7 +1018,8 @@ union CollectionDocumentDeleteResult = | CollectionDocumentNotFound extend type Query { - collectionDocument(id: ID!): CollectionDocumentResult @canAccessCollectionDocument + collectionDocument(id: ID!): CollectionDocumentResult + @canAccessCollectionDocument } extend type Mutation {