diff --git a/cmd/deploy/cmd.go b/cmd/deploy/cmd.go index 2ba04e4..ca8b25c 100644 --- a/cmd/deploy/cmd.go +++ b/cmd/deploy/cmd.go @@ -58,15 +58,15 @@ var cmdArgs struct { func run(cmd *cobra.Command, args []string) error { sc := gql.NewSubscriptionClient().WithSyncMode(true) service := app.New(gql.NewClient(), sc, http.DefaultClient) - input := DeployInput{ - AppDir: cmdArgs.appDir, - ProjectDir: cmdArgs.projectDir, - OrgSlug: cmdArgs.appIdent.OrganizationSlug, - AppSlug: cmdArgs.appIdent.AppSlug, - Message: cmdArgs.message, - Version: cmdArgs.version, - Verbose: cmdArgs.verbose, - Follow: cmdArgs.follow, + input := deployInput{ + appDir: cmdArgs.appDir, + projectDir: cmdArgs.projectDir, + orgSlug: cmdArgs.appIdent.OrganizationSlug, + appSlug: cmdArgs.appIdent.AppSlug, + message: cmdArgs.message, + version: cmdArgs.version, + verbose: cmdArgs.verbose, + follow: cmdArgs.follow, } err := deploy(cmd.Context(), service, input) diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go index 9b751fa..23c55ef 100644 --- a/cmd/deploy/deploy.go +++ b/cmd/deploy/deploy.go @@ -36,7 +36,7 @@ func (e *deployBuildError) Error() string { return e.Message } -type AppService interface { +type appService interface { ReadApp(ctx context.Context, input app.ReadAppInput) (app.ReadAppOutput, error) Create(ctx context.Context, input app.CreateAppInput) (app.CreateAppOutput, error) CreateVersion(ctx context.Context, input app.CreateAppVersionInput) (app.CreateAppVersionOutput, error) @@ -47,21 +47,21 @@ type AppService interface { AppDeployLogs(appident.AppIdentifier) (chan app.AppDeployLogEntry, error) } -type DeployInput struct { - AppDir string - ProjectDir string - OrgSlug string - AppSlug string - Version string - Message string - Verbose bool - Follow bool +type deployInput struct { + appDir string + projectDir string + orgSlug string + appSlug string + version string + message string + verbose bool + follow bool } -func deploy(ctx context.Context, apps AppService, input DeployInput) error { +func deploy(ctx context.Context, apps appService, input deployInput) error { appRelativePath, err := findAppRelativePath(input) if err != nil { - output.PrintError("Project directory %q must be a parent of app directory %q", "", input.ProjectDir, input.AppDir) + output.PrintError("Project directory %q must be a parent of app directory %q", "", input.projectDir, input.appDir) return err } @@ -98,7 +98,7 @@ func deploy(ctx context.Context, apps AppService, input DeployInput) error { output.PrintlnOK("Access your app at: " + links.GetAppURL(orgSlug, appSlug)) - if input.Follow { + if input.follow { output.Notify("Following logs of %s/%s:", "", orgSlug, appSlug) if err := followLogs(ctx, apps, orgSlug, appSlug); err != nil { return err @@ -110,13 +110,13 @@ func deploy(ctx context.Context, apps AppService, input DeployInput) error { fmt.Println("Or you can use the " + output.Highlight("--follow") + " flag:") projectDirArg := "" - if input.ProjectDir != "" { - projectDirArg = " --project-dir=" + input.ProjectDir + if input.projectDir != "" { + projectDirArg = " --project-dir=" + input.projectDir } appDirArg := "" - if input.AppDir != "" { - appDirArg = " " + input.AppDir + if input.appDir != "" { + appDirArg = " " + input.appDir } fmt.Println(" " + output.Highlight("numerous deploy --follow --organization="+orgSlug+" --app="+appSlug+projectDirArg+appDirArg)) @@ -125,17 +125,17 @@ func deploy(ctx context.Context, apps AppService, input DeployInput) error { return nil } -func findAppRelativePath(input DeployInput) (string, error) { - if input.ProjectDir == "" { +func findAppRelativePath(input deployInput) (string, error) { + if input.projectDir == "" { return "", nil } - absProjectDir, err := filepath.Abs(input.ProjectDir) + absProjectDir, err := filepath.Abs(input.projectDir) if err != nil { return "", err } - absAppDir, err := filepath.Abs(input.AppDir) + absAppDir, err := filepath.Abs(input.appDir) if err != nil { return "", err } @@ -152,24 +152,24 @@ func findAppRelativePath(input DeployInput) (string, error) { return filepath.ToSlash(rel), nil } -func loadAppConfiguration(input DeployInput) (*manifest.Manifest, map[string]string, error) { +func loadAppConfiguration(input deployInput) (*manifest.Manifest, map[string]string, error) { task := output.StartTask("Loading app configuration") - m, err := manifest.Load(filepath.Join(input.AppDir, manifest.ManifestFileName)) + m, err := manifest.Load(filepath.Join(input.appDir, manifest.ManifestFileName)) if err != nil { task.Error() - output.PrintErrorAppNotInitialized(input.AppDir) + output.PrintErrorAppNotInitialized(input.appDir) output.PrintManifestTOMLError(err) return nil, nil, err } - secrets := loadSecretsFromEnv(input.AppDir) + secrets := loadSecretsFromEnv(input.appDir) // for validation - ai, err := appident.GetAppIdentifier(input.AppDir, m, input.OrgSlug, input.AppSlug) + ai, err := appident.GetAppIdentifier(input.appDir, m, input.orgSlug, input.appSlug) if err != nil { task.Error() - appident.PrintGetAppIdentifierError(err, input.AppDir, ai) + appident.PrintGetAppIdentifierError(err, input.appDir, ai) return nil, nil, err } @@ -179,10 +179,10 @@ func loadAppConfiguration(input DeployInput) (*manifest.Manifest, map[string]str return m, secrets, nil } -func createAppArchive(input DeployInput, manifest *manifest.Manifest) (*os.File, error) { - srcPath := input.AppDir - if input.ProjectDir != "" { - srcPath = input.ProjectDir +func createAppArchive(input deployInput, manifest *manifest.Manifest) (*os.File, error) { + srcPath := input.appDir + if input.projectDir != "" { + srcPath = input.projectDir } task := output.StartTask("Creating app archive") @@ -208,10 +208,10 @@ func createAppArchive(input DeployInput, manifest *manifest.Manifest) (*os.File, return archive, nil } -func registerAppVersion(ctx context.Context, apps AppService, input DeployInput, manifest *manifest.Manifest) (app.CreateAppVersionOutput, string, string, error) { - ai, err := appident.GetAppIdentifier("", manifest, input.OrgSlug, input.AppSlug) +func registerAppVersion(ctx context.Context, apps appService, input deployInput, manifest *manifest.Manifest) (app.CreateAppVersionOutput, string, string, error) { + ai, err := appident.GetAppIdentifier("", manifest, input.orgSlug, input.appSlug) if err != nil { - appident.PrintGetAppIdentifierError(err, input.AppDir, ai) + appident.PrintGetAppIdentifierError(err, input.appDir, ai) return app.CreateAppVersionOutput{}, "", "", err } @@ -229,7 +229,7 @@ func registerAppVersion(ctx context.Context, apps AppService, input DeployInput, return app.CreateAppVersionOutput{}, "", "", err } - appVersionInput := app.CreateAppVersionInput{AppID: appID, Version: input.Version, Message: input.Message} + appVersionInput := app.CreateAppVersionInput{AppID: appID, Version: input.version, Message: input.message} appVersionOutput, err := apps.CreateVersion(ctx, appVersionInput) if err != nil { task.Error() @@ -242,7 +242,7 @@ func registerAppVersion(ctx context.Context, apps AppService, input DeployInput, return appVersionOutput, ai.OrganizationSlug, ai.AppSlug, nil } -func readOrCreateApp(ctx context.Context, apps AppService, ai appident.AppIdentifier, manifest *manifest.Manifest) (string, error) { +func readOrCreateApp(ctx context.Context, apps appService, ai appident.AppIdentifier, manifest *manifest.Manifest) (string, error) { appReadInput := app.ReadAppInput{ OrganizationSlug: ai.OrganizationSlug, AppSlug: ai.AppSlug, @@ -298,7 +298,7 @@ func (pr *progressReader) report(eof bool) { pr.task.Progress(percent) } -func uploadAppArchive(ctx context.Context, apps AppService, archive *os.File, appVersionID string) error { +func uploadAppArchive(ctx context.Context, apps appService, archive *os.File, appVersionID string) error { task := output.StartTask("Uploading app archive") uploadURLInput := app.AppVersionUploadURLInput(app.AppVersionUploadURLInput{AppVersionID: appVersionID}) uploadURLOutput, err := apps.AppVersionUploadURL(ctx, uploadURLInput) @@ -399,7 +399,7 @@ func printAppSourceUploadErr(appSourceUploadErr *app.AppSourceUploadError) { ) } -func deployApp(ctx context.Context, appVersionOutput app.CreateAppVersionOutput, secrets map[string]string, apps AppService, input DeployInput, appRelativePath string) error { +func deployApp(ctx context.Context, appVersionOutput app.CreateAppVersionOutput, secrets map[string]string, apps appService, input deployInput, appRelativePath string) error { task := output.StartTask("Deploying app") deployAppInput := app.DeployAppInput{AppVersionID: appVersionOutput.AppVersionID, Secrets: secrets, AppRelativePath: appRelativePath} @@ -411,19 +411,19 @@ func deployApp(ctx context.Context, appVersionOutput app.CreateAppVersionOutput, return err } - appDeploymentStatusEventUpdater := statusUpdater{verbose: input.Verbose, task: task} + appDeploymentStatusEventUpdater := statusUpdater{verbose: input.verbose, task: task} eventsInput := app.DeployEventsInput{ DeploymentVersionID: deployAppOutput.DeploymentVersionID, Handler: func(de app.DeployEvent) error { switch de.Typename { case "AppBuildMessageEvent": - if input.Verbose { + if input.verbose { for _, l := range strings.Split(de.BuildMessage.Message, "\n") { task.AddLine("Build", l) } } case "AppBuildErrorEvent": - if input.Verbose { + if input.verbose { for _, l := range strings.Split(de.BuildError.Message, "\n") { task.AddLine("Error", l) } @@ -495,7 +495,7 @@ func loadSecretsFromEnv(appDir string) map[string]string { return env } -func followLogs(ctx context.Context, apps AppService, orgSlug, appSlug string) error { +func followLogs(ctx context.Context, apps appService, orgSlug, appSlug string) error { ai := appident.AppIdentifier{OrganizationSlug: orgSlug, AppSlug: appSlug} ch, err := apps.AppDeployLogs(ai) if err != nil { diff --git a/cmd/deploy/deploy_test.go b/cmd/deploy/deploy_test.go index 8376700..19e5ece 100644 --- a/cmd/deploy/deploy_test.go +++ b/cmd/deploy/deploy_test.go @@ -63,7 +63,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app", appDir) apps := mockAppNotExists() - input := DeployInput{AppDir: appDir, OrgSlug: slug, AppSlug: appSlug} + input := deployInput{appDir: appDir, orgSlug: slug, appSlug: appSlug} err := deploy(context.TODO(), apps, input) assert.NoError(t, err) @@ -74,7 +74,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app", appDir) apps := mockAppExists() - input := DeployInput{AppDir: appDir, OrgSlug: slug, AppSlug: appSlug} + input := deployInput{appDir: appDir, orgSlug: slug, appSlug: appSlug} err := deploy(context.TODO(), apps, input) assert.NoError(t, err) @@ -84,7 +84,7 @@ func TestDeploy(t *testing.T) { t.Run("given dir without numerous.toml then it returns error", func(t *testing.T) { dir := t.TempDir() - input := DeployInput{AppDir: dir, OrgSlug: slug, AppSlug: appSlug} + input := deployInput{appDir: dir, orgSlug: slug, appSlug: appSlug} err := deploy(context.TODO(), nil, input) assert.EqualError(t, err, "open "+dir+"/numerous.toml: no such file or directory") @@ -94,7 +94,7 @@ func TestDeploy(t *testing.T) { appDir := t.TempDir() test.CopyDir(t, "../../testdata/streamlit_app", appDir) - input := DeployInput{AppDir: appDir, OrgSlug: "Some Invalid Organization Slug", AppSlug: appSlug} + input := deployInput{appDir: appDir, orgSlug: "Some Invalid Organization Slug", appSlug: appSlug} err := deploy(context.TODO(), nil, input) assert.ErrorIs(t, err, appident.ErrInvalidOrganizationSlug) @@ -109,7 +109,7 @@ func TestDeploy(t *testing.T) { appDir := t.TempDir() test.CopyDir(t, "../../testdata/streamlit_app_without_deploy", appDir) - input := DeployInput{AppDir: appDir, AppSlug: appSlug} + input := deployInput{appDir: appDir, appSlug: appSlug} err := deploy(context.TODO(), nil, input) assert.ErrorIs(t, err, appident.ErrMissingOrganizationSlug) @@ -120,7 +120,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app_without_deploy", appDir) apps := mockAppNotExists() - input := DeployInput{AppDir: appDir, AppSlug: "app-slug-in-argument", OrgSlug: "organization-slug-in-argument"} + input := deployInput{appDir: appDir, appSlug: "app-slug-in-argument", orgSlug: "organization-slug-in-argument"} err := deploy(context.TODO(), apps, input) if assert.NoError(t, err) { @@ -133,7 +133,7 @@ func TestDeploy(t *testing.T) { appDir := t.TempDir() test.CopyDir(t, "../../testdata/streamlit_app", appDir) - input := DeployInput{AppDir: appDir, OrgSlug: "organization-slug", AppSlug: "Some Invalid App Name"} + input := deployInput{appDir: appDir, orgSlug: "organization-slug", appSlug: "Some Invalid App Name"} err := deploy(context.TODO(), nil, input) assert.ErrorIs(t, err, appident.ErrInvalidAppSlug) @@ -144,7 +144,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app_without_deploy", appDir) apps := mockAppNotExists() - input := DeployInput{AppDir: appDir, OrgSlug: "organization-slug"} + input := deployInput{appDir: appDir, orgSlug: "organization-slug"} err := deploy(context.TODO(), apps, input) expectedAppSlug := "streamlit-app-without-deploy" @@ -159,7 +159,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app", appDir) apps := mockAppNotExists() - err := deploy(context.TODO(), apps, DeployInput{AppDir: appDir}) + err := deploy(context.TODO(), apps, deployInput{appDir: appDir}) if assert.NoError(t, err) { expectedInput := app.CreateAppInput{OrganizationSlug: "organization-slug-in-manifest", AppSlug: "app-slug-in-manifest", DisplayName: "Streamlit App With Deploy"} @@ -172,7 +172,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app", appDir) apps := mockAppNotExists() - input := DeployInput{AppDir: appDir, OrgSlug: "organization-slug-in-argument", AppSlug: "app-slug-in-argument"} + input := deployInput{appDir: appDir, orgSlug: "organization-slug-in-argument", appSlug: "app-slug-in-argument"} err := deploy(context.TODO(), apps, input) if assert.NoError(t, err) { @@ -188,7 +188,7 @@ func TestDeploy(t *testing.T) { expectedMessage := "expected message" apps := mockAppExists() - input := DeployInput{AppDir: appDir, OrgSlug: slug, AppSlug: appSlug, Version: expectedVersion, Message: expectedMessage} + input := deployInput{appDir: appDir, orgSlug: slug, appSlug: appSlug, version: expectedVersion, message: expectedMessage} err := deploy(context.TODO(), apps, input) if assert.NoError(t, err) { @@ -206,7 +206,7 @@ func TestDeploy(t *testing.T) { test.CopyDir(t, "../../testdata/streamlit_app", appDir) apps := mockAppExists() - input := DeployInput{AppDir: appDir, OrgSlug: slug, AppSlug: appSlug} + input := deployInput{appDir: appDir, orgSlug: slug, appSlug: appSlug} err := deploy(context.TODO(), apps, input) if assert.NoError(t, err) { @@ -230,7 +230,7 @@ func TestDeploy(t *testing.T) { input.Handler(app.DeployEvent{Typename: "AppDeploymentStatusEvent", DeploymentStatus: app.AppDeploymentStatusEvent{Status: "RUNNING"}}) // nolint:errcheck }) - input := DeployInput{AppDir: appDir, OrgSlug: slug, AppSlug: appSlug, Version: expectedVersion, Message: expectedMessage, Verbose: true} + input := deployInput{appDir: appDir, orgSlug: slug, appSlug: appSlug, version: expectedVersion, message: expectedMessage, verbose: true} stdoutR, err := test.RunEWithPatchedStdout(t, func() error { return deploy(context.TODO(), apps, input) }) @@ -279,7 +279,7 @@ func TestDeploy(t *testing.T) { close(ch) apps.On("AppDeployLogs", appident.AppIdentifier{OrganizationSlug: slug, AppSlug: appSlug}).Once().Return(ch, nil) - input := DeployInput{AppDir: appDir, OrgSlug: slug, AppSlug: appSlug, Version: expectedVersion, Message: expectedMessage, Verbose: true, Follow: true} + input := deployInput{appDir: appDir, orgSlug: slug, appSlug: appSlug, version: expectedVersion, message: expectedMessage, verbose: true, follow: true} stdoutR, err := test.RunEWithPatchedStdout(t, func() error { return deploy(context.TODO(), apps, input) }) diff --git a/cmd/deploy/mock.go b/cmd/deploy/mock.go index c575146..41b3155 100644 --- a/cmd/deploy/mock.go +++ b/cmd/deploy/mock.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/mock" ) -var _ AppService = &mockAppService{} +var _ appService = &mockAppService{} type mockAppService struct { mock.Mock 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 {