diff --git a/cli/cmd/deploy/cmd.go b/cli/cmd/deploy/cmd.go new file mode 100644 index 0000000..29bd91c --- /dev/null +++ b/cli/cmd/deploy/cmd.go @@ -0,0 +1,87 @@ +package deploy + +import ( + "fmt" + "net/http" + "os" + + "numerous/cli/internal/app" + "numerous/cli/internal/gql" + + "github.com/spf13/cobra" +) + +var DeployCmd = &cobra.Command{ + Use: "deploy [app directory]", + Run: run, + Short: "Deploy an app to an organization.", + Long: `Deploys an application to an organization on the Numerous platform. + +An apps deployment is identified with the and identifier. +Deploying an app to a given and combination, will override +the existing version. + +The must contain only lower-case alphanumeric characters and dashes. + +After deployment the deployed version of the app is available in the +organization's apps page. + +If no [app directory] is specified, the current working directory is used.`, + Example: ` +If an app has been initialized in the current working directory, and it should +be pushed to the organization "organization-slug-a3ecfh2b", and the app name +"my-app", the following command can be used: + + numerous deploy --organization "organization-slug-a3ecfh2b" --name "my-app" + `, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + fn := cmd.HelpFunc() + fn(cmd, args) + + return fmt.Errorf("accepts only an optional [app directory] as a positional argument, you provided %d arguments", len(args)) + } + + if len(args) == 1 { + appDir = args[0] + } + + return nil + }, +} + +var ( + slug string + appName string + verbose bool + appDir string = "." + projectDir string = "." +) + +func run(cmd *cobra.Command, args []string) { + sc := gql.NewSubscriptionClient().WithSyncMode(true) + service := app.New(gql.NewClient(), sc, http.DefaultClient) + err := Deploy(cmd.Context(), service, appDir, projectDir, slug, appName, verbose) + + if err != nil { + os.Exit(1) + } else { + os.Exit(0) + } +} + +func init() { + flags := DeployCmd.Flags() + flags.StringVarP(&slug, "organization", "o", "", "The organization slug identifier. List available organizations with 'numerous organization list'.") + flags.StringVarP(&appName, "name", "n", "", "A unique name for the application to deploy.") + flags.BoolVarP(&verbose, "verbose", "v", false, "Display detailed information about the app deployment.") + flags.StringVarP(&projectDir, "project-dir", "p", "", "The project directory, which is the build context if using a custom Dockerfile.") + + if err := DeployCmd.MarkFlagRequired("organization"); err != nil { + panic(err.Error()) + } + + if err := DeployCmd.MarkFlagRequired("name"); err != nil { + panic(err.Error()) + } +} diff --git a/cli/cmd/deploy/deploy.go b/cli/cmd/deploy/deploy.go new file mode 100644 index 0000000..85149ab --- /dev/null +++ b/cli/cmd/deploy/deploy.go @@ -0,0 +1,201 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "numerous/cli/cmd/initialize" + "numerous/cli/cmd/output" + "numerous/cli/cmd/validate" + "numerous/cli/dotenv" + "numerous/cli/internal/app" + "numerous/cli/internal/archive" + "numerous/cli/manifest" +) + +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) + AppVersionUploadURL(ctx context.Context, input app.AppVersionUploadURLInput) (app.AppVersionUploadURLOutput, error) + UploadAppSource(uploadURL string, archive io.Reader) error + DeployApp(ctx context.Context, input app.DeployAppInput) (app.DeployAppOutput, error) + DeployEvents(ctx context.Context, input app.DeployEventsInput) error +} + +var ( + ErrInvalidSlug = errors.New("invalid organization slug") + ErrInvalidAppName = errors.New("invalid app name") +) + +func Deploy(ctx context.Context, apps AppService, appDir, projectDir, slug string, appName string, verbose bool) error { + if !validate.IsValidIdentifier(slug) { + output.PrintError("Error: Invalid organization %q.", "Must contain only lower-case alphanumerical characters and dashes.", slug) + return ErrInvalidSlug + } + + if !validate.IsValidIdentifier(appName) { + output.PrintError("Error: Invalid app name %q.", "Must contain only lower-case alphanumerical characters and dashes.", appName) + return ErrInvalidAppName + } + + task := output.StartTask("Loading app configuration") + manifest, err := manifest.LoadManifest(filepath.Join(appDir, manifest.ManifestPath)) + if err != nil { + task.Error() + output.PrintErrorAppNotInitialized(appDir) + + return err + } + + secrets := loadSecretsFromEnv(appDir) + task.Done() + + task = output.StartTask("Registering new version") + appID, err := readOrCreateApp(ctx, apps, slug, appName, manifest, task) + if err != nil { + return err + } + + appVersionInput := app.CreateAppVersionInput{AppID: appID} + appVersionOutput, err := apps.CreateVersion(ctx, appVersionInput) + if err != nil { + task.Error() + output.PrintErrorDetails("Error creating app version remotely", err) + + return err + } + task.Done() + + task = output.StartTask("Creating app archive") + tarSrcDir := appDir + if projectDir != "" { + tarSrcDir = projectDir + } + tarPath := path.Join(tarSrcDir, ".tmp_app_archive.tar") + err = archive.TarCreate(tarSrcDir, tarPath, manifest.Exclude) + if err != nil { + task.Error() + output.PrintErrorDetails("Error archiving app source", err) + + return err + } + defer os.Remove(tarPath) + + archive, err := os.Open(tarPath) + if err != nil { + task.Error() + output.PrintErrorDetails("Error archiving app source", err) + + return err + } + defer archive.Close() + task.Done() + + task = output.StartTask("Uploading app archive") + uploadURLInput := app.AppVersionUploadURLInput(appVersionOutput) + uploadURLOutput, err := apps.AppVersionUploadURL(ctx, uploadURLInput) + if err != nil { + task.Error() + output.PrintErrorDetails("Error creating app version remotely", err) + + return err + } + + err = apps.UploadAppSource(uploadURLOutput.UploadURL, archive) + if err != nil { + task.Error() + output.PrintErrorDetails("Error uploading app source archive", err) + + return err + } + task.Done() + + task = output.StartTask("Deploying app") + deployAppInput := app.DeployAppInput{AppVersionID: appVersionOutput.AppVersionID, Secrets: secrets} + deployAppOutput, err := apps.DeployApp(ctx, deployAppInput) + if err != nil { + task.Error() + output.PrintErrorDetails("Error deploying app", err) + } + + input := app.DeployEventsInput{ + DeploymentVersionID: deployAppOutput.DeploymentVersionID, + Handler: func(de app.DeployEvent) error { + switch de.Typename { + case "AppBuildMessageEvent": + for _, l := range strings.Split(de.BuildMessage.Message, "\n") { + task.AddLine("Build", l) + } + case "AppBuildErrorEvent": + for _, l := range strings.Split(de.BuildError.Message, "\n") { + task.AddLine("Error", l) + } + + return fmt.Errorf("build error: %s", de.BuildError.Message) + case "AppDeployStatusEvent": + task.AddLine("Deploy", "Status: "+de.DeploymentStatus.Status) + switch de.DeploymentStatus.Status { + case "PENDING", "RUNNING": + default: + return fmt.Errorf("got status %s while deploying", de.DeploymentStatus.Status) + } + } + + return nil + }, + } + err = apps.DeployEvents(ctx, input) + if err != nil { + task.Error() + output.PrintErrorDetails("Error occurred during deploy", err) + } else { + task.Done() + } + + return nil +} + +func readOrCreateApp(ctx context.Context, apps AppService, slug string, appName string, manifest *manifest.Manifest, task *output.Task) (string, error) { + appReadInput := app.ReadAppInput{ + OrganizationSlug: slug, + Name: appName, + } + appReadOutput, err := apps.ReadApp(ctx, appReadInput) + switch { + case err == nil: + return appReadOutput.AppID, nil + case errors.Is(err, app.ErrAppNotFound): + appCreateInput := app.CreateAppInput{ + OrganizationSlug: slug, + Name: appName, + DisplayName: manifest.Name, + Description: manifest.Description, + } + appCreateOutput, err := apps.Create(ctx, appCreateInput) + if err != nil { + task.Error() + output.PrintErrorDetails("Error creating app remotely", err) + + return "", err + } + + return appCreateOutput.AppID, nil + default: + output.PrintErrorDetails("Error reading remote app", err) + task.Error() + + return "", err + } +} + +func loadSecretsFromEnv(appDir string) map[string]string { + env, _ := dotenv.Load(path.Join(appDir, initialize.EnvFileName)) + return env +} diff --git a/cli/cmd/deploy/deploy_test.go b/cli/cmd/deploy/deploy_test.go new file mode 100644 index 0000000..57be4ed --- /dev/null +++ b/cli/cmd/deploy/deploy_test.go @@ -0,0 +1,116 @@ +package deploy + +import ( + "context" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "testing" + + "numerous/cli/internal/app" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeploy(t *testing.T) { + const slug = "organization-slug" + const appID = "app-id" + const appName = "app-name" + const appVersionID = "app-version-id" + const uploadURL = "https://upload/url" + const deployVersionID = "deploy-version-id" + + t.Run("give no existing app then happy path can run", func(t *testing.T) { + appDir := t.TempDir() + copyTo(t, "../../testdata/streamlit_app", appDir) + + apps := &mockAppService{} + apps.On("ReadApp", mock.Anything, mock.Anything).Return(app.ReadAppOutput{}, app.ErrAppNotFound) + apps.On("Create", mock.Anything, mock.Anything).Return(app.CreateAppOutput{AppID: appID}, nil) + apps.On("CreateVersion", mock.Anything, mock.Anything).Return(app.CreateAppVersionOutput{AppVersionID: appVersionID}, nil) + apps.On("AppVersionUploadURL", mock.Anything, mock.Anything).Return(app.AppVersionUploadURLOutput{UploadURL: uploadURL}, nil) + apps.On("UploadAppSource", mock.Anything, mock.Anything).Return(nil) + apps.On("DeployApp", mock.Anything, mock.Anything).Return(app.DeployAppOutput{DeploymentVersionID: deployVersionID}, nil) + apps.On("DeployEvents", mock.Anything, mock.Anything).Return(nil) + + err := Deploy(context.TODO(), apps, appDir, "", slug, appName, false) + + assert.NoError(t, err) + }) + + t.Run("give existing app then it does not create app", func(t *testing.T) { + appDir := t.TempDir() + copyTo(t, "../../testdata/streamlit_app", appDir) + + apps := &mockAppService{} + apps.On("ReadApp", mock.Anything, mock.Anything).Return(app.ReadAppOutput{AppID: appID}, nil) + apps.On("CreateVersion", mock.Anything, mock.Anything).Return(app.CreateAppVersionOutput{AppVersionID: appVersionID}, nil) + apps.On("AppVersionUploadURL", mock.Anything, mock.Anything).Return(app.AppVersionUploadURLOutput{UploadURL: uploadURL}, nil) + apps.On("UploadAppSource", mock.Anything, mock.Anything).Return(nil) + apps.On("DeployApp", mock.Anything, mock.Anything).Return(app.DeployAppOutput{DeploymentVersionID: deployVersionID}, nil) + apps.On("DeployEvents", mock.Anything, mock.Anything).Return(nil) + + err := Deploy(context.TODO(), apps, appDir, "", slug, appName, false) + + assert.NoError(t, err) + }) + + t.Run("given dir without numerous.toml then it returns error", func(t *testing.T) { + dir := t.TempDir() + + err := Deploy(context.TODO(), nil, dir, "", slug, appName, false) + + assert.EqualError(t, err, "open "+dir+"/numerous.toml: no such file or directory") + }) + + t.Run("given invalid slug then it returns error", func(t *testing.T) { + err := Deploy(context.TODO(), nil, ".", "", "Some Invalid Organization Slug", appName, false) + + assert.ErrorIs(t, err, ErrInvalidSlug) + }) + + t.Run("given invalid app name then it returns error", func(t *testing.T) { + err := Deploy(context.TODO(), nil, ".", "", slug, "Some Invalid App Name", false) + + assert.ErrorIs(t, err, ErrInvalidAppName) + }) +} + +func copyTo(t *testing.T, src string, dest string) { + t.Helper() + + err := filepath.Walk(src, func(p string, info fs.FileInfo, err error) error { + require.NoError(t, err) + if p == src { + return nil + } + + rel, err := filepath.Rel(src, p) + require.NoError(t, err) + destPath := path.Join(dest, rel) + + if info.IsDir() { + err := os.Mkdir(p, os.ModePerm) + require.NoError(t, err) + + return nil + } + + file, err := os.Open(p) + require.NoError(t, err) + + data, err := io.ReadAll(file) + require.NoError(t, err) + + err = os.WriteFile(destPath, data, fs.ModePerm) + require.NoError(t, err) + + return nil + }) + + require.NoError(t, err) +} diff --git a/cli/cmd/deploy/mock.go b/cli/cmd/deploy/mock.go new file mode 100644 index 0000000..2005e04 --- /dev/null +++ b/cli/cmd/deploy/mock.go @@ -0,0 +1,58 @@ +package deploy + +import ( + "context" + "io" + + "numerous/cli/internal/app" + + "github.com/stretchr/testify/mock" +) + +var _ AppService = &mockAppService{} + +type mockAppService struct { + mock.Mock +} + +// ReadApp implements AppService. +func (m *mockAppService) ReadApp(ctx context.Context, input app.ReadAppInput) (app.ReadAppOutput, error) { + args := m.Called(ctx, input) + return args.Get(0).(app.ReadAppOutput), args.Error(1) +} + +// DeployEvents implements AppService. +func (m *mockAppService) DeployEvents(ctx context.Context, input app.DeployEventsInput) error { + args := m.Called(ctx, input) + return args.Error(0) +} + +// DeployApp implements AppService. +func (m *mockAppService) DeployApp(ctx context.Context, input app.DeployAppInput) (app.DeployAppOutput, error) { + args := m.Called(ctx, input) + return args.Get(0).(app.DeployAppOutput), args.Error(1) +} + +// AppVersionUploadURL implements AppService. +func (m *mockAppService) AppVersionUploadURL(ctx context.Context, input app.AppVersionUploadURLInput) (app.AppVersionUploadURLOutput, error) { + args := m.Called(ctx, input) + return args.Get(0).(app.AppVersionUploadURLOutput), args.Error(1) +} + +// Create implements AppService. +func (m *mockAppService) Create(ctx context.Context, input app.CreateAppInput) (app.CreateAppOutput, error) { + args := m.Called(ctx, input) + return args.Get(0).(app.CreateAppOutput), args.Error(1) +} + +// CreateVersion implements AppService. +func (m *mockAppService) CreateVersion(ctx context.Context, input app.CreateAppVersionInput) (app.CreateAppVersionOutput, error) { + args := m.Called(ctx, input) + return args.Get(0).(app.CreateAppVersionOutput), args.Error(1) +} + +// UploadAppSource implements AppService. +func (m *mockAppService) UploadAppSource(uploadURL string, archive io.Reader) error { + args := m.Called(uploadURL, archive) + return args.Error(0) +} diff --git a/cli/cmd/log/log_events.go b/cli/cmd/log/log_events.go index 5c5038d..a1c44ab 100644 --- a/cli/cmd/log/log_events.go +++ b/cli/cmd/log/log_events.go @@ -87,7 +87,7 @@ func printVerbose(out io.Writer, entry LogEntry, timestamps, verbose bool) { func getClient() SubscriptionClient { var previousError error - client := gql.GetSubscriptionClient() + client := gql.NewSubscriptionClient() client = client.OnError(func(sc *graphql.SubscriptionClient, err error) error { if previousError != nil { output.PrintError( diff --git a/cli/cmd/output/colors.go b/cli/cmd/output/colors.go new file mode 100644 index 0000000..9bc4e5a --- /dev/null +++ b/cli/cmd/output/colors.go @@ -0,0 +1,9 @@ +package output + +const ( + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiReset = "\033[0m" + ansiFaint = "\033[2m" +) diff --git a/cli/cmd/output/error.go b/cli/cmd/output/error.go index e663bc8..273cdfe 100644 --- a/cli/cmd/output/error.go +++ b/cli/cmd/output/error.go @@ -12,7 +12,7 @@ func PrintError(header, body string, args ...any) { body += "\n" } - f := "❌ " + header + "\n" + body + f := errorcross + " " + ansiRed + header + ansiReset + "\n" + ansiYellow + body + ansiReset fmt.Printf(f, args...) } @@ -27,7 +27,13 @@ func PrintUnknownError(err error) { PrintErrorDetails("Sorry! An unexpected error occurred.", err) } -func PrintErrorAppNotInitialized() { - PrintError("The current directory is not a numerous app", - "\nrun \"numerous init\" to initialize a numerous app in the current directory") +func PrintErrorAppNotInitialized(appDir string) { + if appDir == "." { + PrintError("The current directory is not a numerous app", + "Run \"numerous init\" to initialize a numerous app in the current directory.") + } else { + PrintError("The select directory \"%s\" is not a numerous app", + "Run \"numerous init %s\" to initialize a numerous app.", + appDir, appDir) + } } diff --git a/cli/cmd/output/symbols.go b/cli/cmd/output/symbols.go new file mode 100644 index 0000000..ee24a14 --- /dev/null +++ b/cli/cmd/output/symbols.go @@ -0,0 +1,7 @@ +package output + +const ( + hourglass = "\u29D6" + errorcross = ansiRed + "\u2715" + ansiReset + checkmark = ansiGreen + "\u2713" + ansiReset +) diff --git a/cli/cmd/output/task.go b/cli/cmd/output/task.go new file mode 100644 index 0000000..3311966 --- /dev/null +++ b/cli/cmd/output/task.go @@ -0,0 +1,58 @@ +package output + +import ( + "fmt" +) + +const taskLineLength = 40 + +type Task struct { + msg string + length int + lineAdded bool +} + +func (t *Task) line(icon string) string { + ln := icon + " " + t.msg + for d := len(t.msg); d < t.length; d++ { + ln += "." + } + + return ln +} + +func (t *Task) start() { + ln := t.line(hourglass) + fmt.Print(ln) +} + +func (t *Task) AddLine(prefix string, line string) { + if !t.lineAdded { + fmt.Println() + } + fmt.Println(ansiReset+ansiFaint+prefix+ansiReset, line) + t.lineAdded = true +} + +func (t *Task) Done() { + ln := t.line(checkmark) + if !t.lineAdded { + fmt.Print("\r") + } + fmt.Println(ln + ansiGreen + "OK" + ansiReset) +} + +func (t *Task) Error() { + ln := t.line(errorcross) + if !t.lineAdded { + fmt.Print("\r") + } + fmt.Println(ln + ansiRed + "Error" + ansiReset) +} + +func StartTask(msg string) *Task { + t := Task{msg: msg, length: taskLineLength} + t.start() + + return &t +} diff --git a/cli/cmd/push/build_events.go b/cli/cmd/push/build_events.go index 95cebbe..339cf48 100644 --- a/cli/cmd/push/build_events.go +++ b/cli/cmd/push/build_events.go @@ -108,7 +108,7 @@ func buildEventSubscription(client subscriptionClient, buildID string, appPath s func getClient() *graphql.SubscriptionClient { var previousError error - client := gql.GetSubscriptionClient() + client := gql.NewSubscriptionClient() client = client.OnError(func(sc *graphql.SubscriptionClient, err error) error { if previousError != nil { fmt.Printf("Error occurred listening for deploy logs. This does not mean that you app will be unavailable.\nFirst error: %s\nSecond error: %s\n", previousError, err) diff --git a/cli/cmd/push/push.go b/cli/cmd/push/push.go index 4f46e7f..498e8f0 100644 --- a/cli/cmd/push/push.go +++ b/cli/cmd/push/push.go @@ -10,6 +10,7 @@ import ( "numerous/cli/cmd/initialize" "numerous/cli/cmd/output" "numerous/cli/dotenv" + "numerous/cli/internal/archive" "numerous/cli/internal/gql" "numerous/cli/internal/gql/app" "numerous/cli/internal/gql/build" @@ -26,15 +27,6 @@ const ( var verbose bool -var ( - carriageReturn = "\r" - greenColorEscapeANSI = "\033[32m" - resetColorEscapeANSI = "\033[0m" - unicodeCheckmark = "\u2713" - greenCheckmark = carriageReturn + greenColorEscapeANSI + unicodeCheckmark + resetColorEscapeANSI - unicodeHourglass = "\u29D6" -) - const ( ProjectArgLength = 1 ProjectAndAppArgLength = 2 @@ -62,7 +54,7 @@ func push(cmd *cobra.Command, args []string) { m, err := manifest.LoadManifest(filepath.Join(appDir, manifest.ManifestPath)) if err != nil { - output.PrintErrorAppNotInitialized() + output.PrintErrorAppNotInitialized(appDir) os.Exit(1) } @@ -137,7 +129,7 @@ func printURL(toolID string) (ok bool) { } func deployApp(toolID string) (ok bool) { - fmt.Print(unicodeHourglass + " Deploying app......") + task := output.StartTask("Deploying app") err := stopJobs(string(toolID)) if err != nil { @@ -151,13 +143,14 @@ func deployApp(toolID string) (ok bool) { return false } - fmt.Println(greenCheckmark + " Deploying app......Done") + task.Done() return true } func buildApp(buildID string, appPath string) (ok bool) { - fmt.Print(unicodeHourglass + " Building app.......") + task := output.StartTask("Building app") + if verbose { // To allow nice printing of build messages from backend fmt.Println() @@ -168,16 +161,14 @@ func buildApp(buildID string, appPath string) (ok bool) { output.PrintErrorDetails("Error listening for build logs.", err) return false } - - fmt.Println(greenCheckmark + " Building app.......Done") + task.Done() return true } func uploadApp(appDir string, toolID string) (buildID string, ok bool) { defer os.Remove(zipFileName) - - fmt.Print(unicodeHourglass + " Uploading app......") + task := output.StartTask("Uploading app") secrets := loadSecretsFromEnv(appDir) buildID, err := pushBuild(zipFileName, string(toolID), secrets) @@ -197,33 +188,22 @@ func uploadApp(appDir string, toolID string) (buildID string, ok bool) { return "", false } - fmt.Println(greenCheckmark + " Uploading app......Done") + task.Done() return buildID, true } func prepareApp(m *manifest.Manifest) (ok bool) { - if !verbose { - fmt.Print(unicodeHourglass + " Preparing upload...") - } + task := output.StartTask("Preparing upload.") - zipFile, err := os.OpenFile(zipFileName, os.O_CREATE|os.O_RDWR, zipFilePermission) - if err != nil { - output.PrintErrorDetails("Error preparing app.", err) - - return false - } - - if err := ZipFolder(zipFile, m.Exclude); err != nil { + if err := archive.ZipCreate(".", zipFileName, m.Exclude); err != nil { output.PrintErrorDetails("Error preparing app.", err) os.Remove(zipFileName) return false } - zipFile.Close() - - fmt.Println(greenCheckmark + " Preparing upload...Done") + task.Done() return true } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 6df20c6..4507071 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -10,6 +10,7 @@ import ( "numerous/cli/auth" deleteapp "numerous/cli/cmd/delete" + "numerous/cli/cmd/deploy" "numerous/cli/cmd/dev" "numerous/cli/cmd/initialize" "numerous/cli/cmd/list" @@ -92,6 +93,7 @@ func commandRequiresAuthentication(invokedCommandName string) bool { "numerous log", "numerous organization create", "numerous organization list", + "numerous deploy", } for _, cmd := range commandsWithAuthRequired { @@ -116,6 +118,7 @@ func bindCommands() { rootCmd.AddCommand(list.ListCmd) rootCmd.AddCommand(report.ReportCmd) rootCmd.AddCommand(organization.OrganizationRootCmd) + rootCmd.AddCommand(deploy.DeployCmd) organization.OrganizationRootCmd.AddCommand(createorganization.OrganizationCreateCmd) organization.OrganizationRootCmd.AddCommand(listorganization.OrganizationListCmd) } diff --git a/cli/cmd/validate/identifier.go b/cli/cmd/validate/identifier.go new file mode 100644 index 0000000..92477f2 --- /dev/null +++ b/cli/cmd/validate/identifier.go @@ -0,0 +1,8 @@ +package validate + +import "regexp" + +func IsValidIdentifier(i string) bool { + m, err := regexp.Match(`^[a-z0-9-]+$`, []byte(i)) + return m && err == nil +} diff --git a/cli/cmd/validate/identifier_test.go b/cli/cmd/validate/identifier_test.go new file mode 100644 index 0000000..25d97e7 --- /dev/null +++ b/cli/cmd/validate/identifier_test.go @@ -0,0 +1,45 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidIdentifier(t *testing.T) { + for _, invalid := range []string{ + "abcæøå", + "abc[123", + "abc]123", + "abc_123", + "abc|123", + "abc\"123", + "abc'123", + "abc<123", + "abc>123", + "abc#123", + "abc?123", + "abc:123", + "abc;123", + "abcABC123", + } { + t.Run("fails for '"+""+invalid+"'", func(t *testing.T) { + actual := IsValidIdentifier(invalid) + assert.False(t, actual) + }) + } + + for _, valid := range []string{ + "abcdefghijklmopqrstuvwxyz", + "1234567890", + "abcdef123456", + "abcdef-123456", + "123456abcdef", + "123456-abcdef", + } { + t.Run("succeeds for '"+valid+"'", func(t *testing.T) { + actual := IsValidIdentifier(valid) + assert.True(t, actual) + }) + } +} diff --git a/cli/internal/app/app_create.go b/cli/internal/app/app_create.go new file mode 100644 index 0000000..b6ac11e --- /dev/null +++ b/cli/internal/app/app_create.go @@ -0,0 +1,49 @@ +package app + +import ( + "context" +) + +type CreateAppInput struct { + OrganizationSlug string + Name string + DisplayName string + Description string +} + +type CreateAppOutput struct { + AppID string +} + +const appCreateText = ` +mutation AppCreate($slug: String!, $name: String!, $displayName: String!, $description: String!) { + appCreate(organizationSlug: $slug, appData: {name: $name, displayName: $displayName, description: $description}) { + id + } +} +` + +type appCreateResponse struct { + AppCreate struct { + ID string + } +} + +func (s *Service) Create(ctx context.Context, input CreateAppInput) (CreateAppOutput, error) { + var resp appCreateResponse + + variables := map[string]any{ + "slug": input.OrganizationSlug, + "name": input.Name, + "displayName": input.DisplayName, + "description": input.Description, + } + err := s.client.Exec(ctx, appCreateText, &resp, variables) + if err != nil { + return CreateAppOutput{}, err + } + + return CreateAppOutput{ + AppID: resp.AppCreate.ID, + }, nil +} diff --git a/cli/internal/app/app_create_test.go b/cli/internal/app/app_create_test.go new file mode 100644 index 0000000..9927f6a --- /dev/null +++ b/cli/internal/app/app_create_test.go @@ -0,0 +1,75 @@ +package app + +import ( + "context" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestCreate(t *testing.T) { + t.Run("returns expected output", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "data": { + "appCreate": { + "id": "some-app-id" + } + } + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := CreateAppInput{ + OrganizationSlug: "organization-slug", + Name: "app-name", + DisplayName: "App Name", + Description: "App description", + } + output, err := s.Create(context.TODO(), input) + + expected := CreateAppOutput{ + AppID: "some-app-id", + } + assert.NoError(t, err) + assert.Equal(t, expected, output) + }) + + t.Run("returns expected error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "errors": [{ + "message": "expected error message", + "location": [{"line": 1, "column": 1}], + "path": ["appCreate"] + }] + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := CreateAppInput{ + OrganizationSlug: "organization-slug", + Name: "app-name", + DisplayName: "App Name", + Description: "App description", + } + output, err := s.Create(context.TODO(), input) + + expected := CreateAppOutput{} + assert.ErrorContains(t, err, "expected error message") + assert.Equal(t, expected, output) + }) +} diff --git a/cli/internal/app/app_deploy.go b/cli/internal/app/app_deploy.go new file mode 100644 index 0000000..20e269e --- /dev/null +++ b/cli/internal/app/app_deploy.go @@ -0,0 +1,43 @@ +package app + +import ( + "context" + + "numerous/cli/internal/gql/secret" +) + +type DeployAppInput struct { + AppVersionID string + Secrets map[string]string +} + +type DeployAppOutput struct { + DeploymentVersionID string +} + +const appDeployText = ` +mutation AppDeploy($appVersionID: ID!, $secrets: [AppSecret!]) { + appDeploy(appVersionID: $appVersionID, input: {secrets: $secrets}) { + id + } +} +` + +type appDeployResponse struct { + AppDeploy struct { + ID string + } +} + +func (s *Service) DeployApp(ctx context.Context, input DeployAppInput) (DeployAppOutput, error) { + var resp appDeployResponse + convertedSecrets := secret.AppSecretsFromMap(input.Secrets) + variables := map[string]any{"appVersionID": input.AppVersionID, "secrets": convertedSecrets} + + err := s.client.Exec(ctx, appDeployText, &resp, variables) + if err != nil { + return DeployAppOutput{}, err + } + + return DeployAppOutput{DeploymentVersionID: resp.AppDeploy.ID}, nil +} diff --git a/cli/internal/app/app_deploy_test.go b/cli/internal/app/app_deploy_test.go new file mode 100644 index 0000000..4f69518 --- /dev/null +++ b/cli/internal/app/app_deploy_test.go @@ -0,0 +1,69 @@ +package app + +import ( + "context" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestDeployApp(t *testing.T) { + t.Run("returns expected output", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "data": { + "appDeploy": { + "id": "some-deploy-version-id" + } + } + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := DeployAppInput{ + AppVersionID: "some-app-version-id", + Secrets: map[string]string{"SECRET1": "some secret value"}, + } + output, err := s.DeployApp(context.TODO(), input) + + expected := DeployAppOutput{ + DeploymentVersionID: "some-deploy-version-id", + } + assert.NoError(t, err) + assert.Equal(t, expected, output) + }) + + t.Run("returns expected error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "errors": [{ + "message": "expected error message", + "location": [{"line": 1, "column": 1}], + "path": ["appCreate"] + }] + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + input := DeployAppInput{ + AppVersionID: "some-app-version-id", + } + output, err := s.DeployApp(context.TODO(), input) + + expected := DeployAppOutput{} + assert.ErrorContains(t, err, "expected error message") + assert.Equal(t, expected, output) + }) +} diff --git a/cli/internal/app/app_read.go b/cli/internal/app/app_read.go new file mode 100644 index 0000000..477a9e2 --- /dev/null +++ b/cli/internal/app/app_read.go @@ -0,0 +1,49 @@ +package app + +import ( + "context" + "errors" + "strings" +) + +type ReadAppInput struct { + OrganizationSlug string + Name string +} + +type ReadAppOutput struct { + AppID string +} + +const queryAppText = ` +query App($slug: String!, $name: String!) { + app(organizationSlug: $slug, appName: $name) { + id + } +} +` + +type appResponse struct { + App struct { + ID string + } +} + +var ErrAppNotFound = errors.New("app not found") + +func (s *Service) ReadApp(ctx context.Context, input ReadAppInput) (ReadAppOutput, error) { + var resp appResponse + + variables := map[string]any{"slug": input.OrganizationSlug, "name": input.Name} + err := s.client.Exec(ctx, queryAppText, &resp, variables) + if err != nil && !strings.Contains(err.Error(), "app not found") { + return ReadAppOutput{}, err + } + + empty := appResponse{} + if resp == empty { + return ReadAppOutput{}, ErrAppNotFound + } + + return ReadAppOutput{AppID: resp.App.ID}, nil +} diff --git a/cli/internal/app/app_read_test.go b/cli/internal/app/app_read_test.go new file mode 100644 index 0000000..0adf918 --- /dev/null +++ b/cli/internal/app/app_read_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "context" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAppRead(t *testing.T) { + t.Run("given app response it returns expected output", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "data": { + "app": { + "id": "some-app-id" + } + } + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := ReadAppInput{ + OrganizationSlug: "organization-slug", + Name: "app-name", + } + output, err := s.ReadApp(context.TODO(), input) + + expected := ReadAppOutput{ + AppID: "some-app-id", + } + assert.NoError(t, err) + assert.Equal(t, expected, output) + }) + + t.Run("given empty response then it returns not found error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "data": { + "app": null + } + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := ReadAppInput{ + OrganizationSlug: "organization-slug", + Name: "app-name", + } + output, err := s.ReadApp(context.TODO(), input) + + expected := ReadAppOutput{} + assert.ErrorIs(t, err, ErrAppNotFound) + assert.Equal(t, expected, output) + }) + + t.Run("given graphql error then it returns expected error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "errors": [{ + "message": "expected error message", + "location": [{"line": 1, "column": 1}], + "path": ["appCreate"] + }] + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := ReadAppInput{ + OrganizationSlug: "organization-slug", + Name: "app-name", + } + output, err := s.ReadApp(context.TODO(), input) + + expected := ReadAppOutput{} + assert.ErrorContains(t, err, "expected error message") + assert.Equal(t, expected, output) + }) +} diff --git a/cli/internal/app/deploy_events.go b/cli/internal/app/deploy_events.go new file mode 100644 index 0000000..ea36376 --- /dev/null +++ b/cli/internal/app/deploy_events.go @@ -0,0 +1,97 @@ +package app + +import ( + "context" + "errors" + + "github.com/hasura/go-graphql-client" + "github.com/hasura/go-graphql-client/pkg/jsonutil" +) + +type AppBuildMessageEvent struct { + Message string +} + +type AppBuildErrorEvent struct { + Message string +} + +type AppDeploymentStatusEvent struct { + Status string +} + +type DeployEventsInput struct { + DeploymentVersionID string + Handler func(DeployEvent) error +} + +var ErrNoDeployEventsHandler = errors.New("no deploy events handler defined") + +type DeployEvent struct { + Typename string `graphql:"__typename"` + DeploymentStatus AppDeploymentStatusEvent `graphql:"... on AppDeploymentStatusEvent"` + BuildMessage AppBuildMessageEvent `graphql:"... on AppBuildMessageEvent"` + BuildError AppBuildErrorEvent `graphql:"... on AppBuildErrorEvent"` +} + +type DeployEventsSubscription struct { + AppDeployEvents DeployEvent `graphql:"appDeployEvents(appDeploymentVersionID: $deployVersionID)"` +} + +type GraphQLID string + +func (GraphQLID) GetGraphQLType() string { + return "ID" +} + +func (s *Service) DeployEvents(ctx context.Context, input DeployEventsInput) error { + if input.Handler == nil { + return ErrNoDeployEventsHandler + } + defer s.subscription.Close() + + var handlerError error + variables := map[string]any{"deployVersionID": GraphQLID(input.DeploymentVersionID)} + handler := func(message []byte, err error) error { + if err != nil { + return err + } + + var value DeployEventsSubscription + + err = jsonutil.UnmarshalGraphQL(message, &value) + if err != nil { + return err + } + + // clean value + switch value.AppDeployEvents.Typename { + case "AppBuildMessageEvent": + value.AppDeployEvents.BuildError = AppBuildErrorEvent{} + case "AppBuildErrorEvent": + value.AppDeployEvents.BuildMessage = AppBuildMessageEvent{} + } + + // run handler + handlerError = input.Handler(value.AppDeployEvents) + if handlerError != nil { + return graphql.ErrSubscriptionStopped + } + + return nil + } + + _, err := s.subscription.Subscribe(&DeployEventsSubscription{}, variables, handler) + if err != nil { + return nil + } + + err = s.subscription.Run() + + // first we check if the handler found any errors + if handlerError != nil { + return handlerError + } + + return err +} diff --git a/cli/internal/app/deploy_events_test.go b/cli/internal/app/deploy_events_test.go new file mode 100644 index 0000000..e5aea9d --- /dev/null +++ b/cli/internal/app/deploy_events_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "context" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" +) + +func TestDeployEvents(t *testing.T) { + testCases := []struct { + name string + sms []test.SubMessage + expected []DeployEvent + }{ + { + name: "returns expected build messages", + sms: []test.SubMessage{ + {Msg: `{"appDeployEvents": {"__typename": "AppBuildMessageEvent", "message": "message 1"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppBuildMessageEvent", "message": "message 2"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppBuildMessageEvent", "message": "message 3"}}`}, + }, + expected: []DeployEvent{ + {Typename: "AppBuildMessageEvent", BuildMessage: AppBuildMessageEvent{Message: "message 1"}}, + {Typename: "AppBuildMessageEvent", BuildMessage: AppBuildMessageEvent{Message: "message 2"}}, + {Typename: "AppBuildMessageEvent", BuildMessage: AppBuildMessageEvent{Message: "message 3"}}, + }, + }, + { + name: "returns expected deploy status events", + sms: []test.SubMessage{ + {Msg: `{"appDeployEvents": {"__typename": "AppDeploymentStatusEvent", "status": "PENDING"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppDeploymentStatusEvent", "status": "RUNNING"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppDeploymentStatusEvent", "status": "STOPPED"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppDeploymentStatusEvent", "status": "ERROR"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppDeploymentStatusEvent", "status": "UNKNOWN"}}`}, + }, + expected: []DeployEvent{ + {Typename: "AppDeploymentStatusEvent", DeploymentStatus: AppDeploymentStatusEvent{Status: "PENDING"}}, + {Typename: "AppDeploymentStatusEvent", DeploymentStatus: AppDeploymentStatusEvent{Status: "RUNNING"}}, + {Typename: "AppDeploymentStatusEvent", DeploymentStatus: AppDeploymentStatusEvent{Status: "STOPPED"}}, + {Typename: "AppDeploymentStatusEvent", DeploymentStatus: AppDeploymentStatusEvent{Status: "ERROR"}}, + {Typename: "AppDeploymentStatusEvent", DeploymentStatus: AppDeploymentStatusEvent{Status: "UNKNOWN"}}, + }, + }, + { + name: "returns expected build errors", + sms: []test.SubMessage{ + {Msg: `{"appDeployEvents": {"__typename": "AppBuildErrorEvent", "message": "error 1"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppBuildErrorEvent", "message": "error 2"}}`}, + {Msg: `{"appDeployEvents": {"__typename": "AppBuildErrorEvent", "message": "error 3"}}`}, + }, + expected: []DeployEvent{ + {Typename: "AppBuildErrorEvent", BuildError: AppBuildErrorEvent{Message: "error 1"}}, + {Typename: "AppBuildErrorEvent", BuildError: AppBuildErrorEvent{Message: "error 2"}}, + {Typename: "AppBuildErrorEvent", BuildError: AppBuildErrorEvent{Message: "error 3"}}, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ch := make(chan test.SubMessage, 10) + c := test.CreateTestSubscriptionClient(t, ch) + s := New(nil, c, nil) + + actual := []DeployEvent{} + input := DeployEventsInput{ + Handler: func(ev DeployEvent) error { + actual = append(actual, ev) + return nil + }, + DeploymentVersionID: "some-id", + } + err := s.DeployEvents(context.TODO(), input) + for _, sm := range tc.sms { + ch <- sm + } + close(ch) + c.Wait() + + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/cli/internal/app/service.go b/cli/internal/app/service.go new file mode 100644 index 0000000..f683eb3 --- /dev/null +++ b/cli/internal/app/service.go @@ -0,0 +1,31 @@ +package app + +import ( + "net/http" + + "github.com/hasura/go-graphql-client" +) + +type UploadDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type SubscriptionClient interface { + Subscribe(v interface{}, variables map[string]interface{}, handler func(message []byte, err error) error, options ...graphql.Option) (string, error) + Run() error + Close() error +} + +type Service struct { + client *graphql.Client + subscription SubscriptionClient + uploadDoer UploadDoer +} + +func New(client *graphql.Client, subscription SubscriptionClient, uploadDoer UploadDoer) *Service { + return &Service{ + client: client, + subscription: subscription, + uploadDoer: uploadDoer, + } +} diff --git a/cli/internal/app/upload_app_source.go b/cli/internal/app/upload_app_source.go new file mode 100644 index 0000000..1dae5c2 --- /dev/null +++ b/cli/internal/app/upload_app_source.go @@ -0,0 +1,33 @@ +package app + +import ( + "bytes" + "errors" + "io" + "net/http" +) + +var ErrAppSourceUpload = errors.New("error uploading app source") + +func (s *Service) UploadAppSource(uploadURL string, archive io.Reader) error { + var buf bytes.Buffer + if _, err := io.Copy(&buf, archive); err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPut, uploadURL, &buf) + if err != nil { + return err + } + + resp, err := s.uploadDoer.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return ErrAppSourceUpload + } + + return nil +} diff --git a/cli/internal/app/upload_app_source_test.go b/cli/internal/app/upload_app_source_test.go new file mode 100644 index 0000000..ce08266 --- /dev/null +++ b/cli/internal/app/upload_app_source_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func dummyReader() io.Reader { + return bytes.NewBuffer([]byte("some data")) +} + +func TestUploadAppSource(t *testing.T) { + testError := errors.New("some test error") + + t.Run("given http client error then it returns error", func(t *testing.T) { + doer := test.MockDoer{} + var nilResp *http.Response + doer.On("Do", mock.Anything).Return(nilResp, testError) + s := Service{uploadDoer: &doer} + + err := s.UploadAppSource("http://some-upload-url", dummyReader()) + + assert.ErrorIs(t, err, testError) + }) + + t.Run("given non-OK http status then it returns error", func(t *testing.T) { + doer := test.MockDoer{} + resp := http.Response{Status: "Not OK", StatusCode: http.StatusBadRequest} + doer.On("Do", mock.Anything).Return(&resp, nil) + s := Service{uploadDoer: &doer} + + err := s.UploadAppSource("http://some-upload-url", dummyReader()) + + assert.ErrorIs(t, err, ErrAppSourceUpload) + }) + + t.Run("given invalid upload URL then it returns error", func(t *testing.T) { + s := Service{uploadDoer: &test.MockDoer{}} + + err := s.UploadAppSource("://invalid-url", dummyReader()) + + assert.Error(t, err) + }) + + t.Run("given successful request then it returns no error", func(t *testing.T) { + doer := test.MockDoer{} + resp := http.Response{Status: "OK", StatusCode: http.StatusOK} + doer.On("Do", mock.Anything).Return(&resp, nil) + s := Service{uploadDoer: &doer} + + err := s.UploadAppSource("http://some-upload-url", bytes.NewReader([]byte(""))) + + assert.NoError(t, err) + }) + + t.Run("it sends expected content-length header", func(t *testing.T) { + doer := test.MockDoer{} + resp := http.Response{Status: "OK", StatusCode: http.StatusOK} + doer.On("Do", mock.Anything).Return(&resp, nil) + s := Service{uploadDoer: &doer} + err := s.UploadAppSource("http://some-upload-url", bytes.NewReader([]byte("some data"))) + + assert.NoError(t, err) + doer.AssertCalled(t, "Do", mock.MatchedBy(func(r *http.Request) bool { + return r.ContentLength == 9 + })) + }) +} diff --git a/cli/internal/app/version_create.go b/cli/internal/app/version_create.go new file mode 100644 index 0000000..277a9a2 --- /dev/null +++ b/cli/internal/app/version_create.go @@ -0,0 +1,43 @@ +package app + +import ( + "context" +) + +type CreateAppVersionInput struct { + AppID string +} + +type CreateAppVersionOutput struct { + AppVersionID string +} + +const appVersionCreateText = ` +mutation AppVersionCreate($appID: ID!) { + appVersionCreate(appID: $appID) { + id + } +} +` + +type appVersionCreateResponse struct { + AppVersionCreate struct { + ID string + } +} + +func (s *Service) CreateVersion(ctx context.Context, input CreateAppVersionInput) (CreateAppVersionOutput, error) { + var resp appVersionCreateResponse + + variables := map[string]any{ + "appID": input.AppID, + } + err := s.client.Exec(ctx, appVersionCreateText, &resp, variables) + if err != nil { + return CreateAppVersionOutput{}, err + } + + return CreateAppVersionOutput{ + AppVersionID: resp.AppVersionCreate.ID, + }, nil +} diff --git a/cli/internal/app/version_create_test.go b/cli/internal/app/version_create_test.go new file mode 100644 index 0000000..cdcbbc1 --- /dev/null +++ b/cli/internal/app/version_create_test.go @@ -0,0 +1,63 @@ +package app + +import ( + "context" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestCreateVersion(t *testing.T) { + t.Run("returns expected output", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "data": { + "appVersionCreate": { + "id": "some-app-version-id" + } + } + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := CreateAppVersionInput{AppID: "some-app-id"} + output, err := s.CreateVersion(context.TODO(), input) + + expected := CreateAppVersionOutput{AppVersionID: "some-app-version-id"} + assert.NoError(t, err) + assert.Equal(t, expected, output) + }) + + t.Run("returns expected error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "errors": [{ + "message": "expected error message", + "location": [{"line": 1, "column": 1}], + "path": ["appVersionCreate"] + }] + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := CreateAppVersionInput{AppID: "some-app-id"} + output, err := s.CreateVersion(context.TODO(), input) + + expected := CreateAppVersionOutput{} + assert.ErrorContains(t, err, "expected error message") + assert.Equal(t, expected, output) + }) +} diff --git a/cli/internal/app/version_upload_url.go b/cli/internal/app/version_upload_url.go new file mode 100644 index 0000000..87cf681 --- /dev/null +++ b/cli/internal/app/version_upload_url.go @@ -0,0 +1,41 @@ +package app + +import ( + "context" +) + +type AppVersionUploadURLInput struct { + AppVersionID string +} + +type AppVersionUploadURLOutput struct { + UploadURL string +} + +const appVersionUploadURLText = ` +mutation AppVersionUploadURL($appVersionID: ID!) { + appVersionUploadURL(appVersionID: $appVersionID) { + url + } +} +` + +type appVersionUploadURLResponse struct { + AppVersionUploadURL struct { + URL string + } +} + +func (s *Service) AppVersionUploadURL(ctx context.Context, input AppVersionUploadURLInput) (AppVersionUploadURLOutput, error) { + var resp appVersionUploadURLResponse + + variables := map[string]any{ + "appVersionID": input.AppVersionID, + } + err := s.client.Exec(ctx, appVersionUploadURLText, &resp, variables) + if err != nil { + return AppVersionUploadURLOutput{}, err + } + + return AppVersionUploadURLOutput{UploadURL: resp.AppVersionUploadURL.URL}, nil +} diff --git a/cli/internal/app/version_upload_url_test.go b/cli/internal/app/version_upload_url_test.go new file mode 100644 index 0000000..d09e0d0 --- /dev/null +++ b/cli/internal/app/version_upload_url_test.go @@ -0,0 +1,65 @@ +package app + +import ( + "context" + "testing" + + "numerous/cli/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetAppVersionUploadURL(t *testing.T) { + t.Run("returns expected output", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "data": { + "appVersionUploadURL": { + "url": "https://upload-url.com/url-path" + } + } + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := AppVersionUploadURLInput{AppVersionID: "some-app-version-id"} + output, err := s.AppVersionUploadURL(context.TODO(), input) + + expected := AppVersionUploadURLOutput{UploadURL: "https://upload-url.com/url-path"} + if assert.NoError(t, err) { + assert.Equal(t, expected, output) + } + }) + + t.Run("returns expected error", func(t *testing.T) { + doer := test.MockDoer{} + c := test.CreateTestGQLClient(t, &doer) + s := New(c, nil, nil) + + respBody := ` + { + "errors": [{ + "message": "expected error message", + "location": [{"line": 1, "column": 1}], + "path": ["appVersionUploadURL"] + }] + } + ` + resp := test.JSONResponse(respBody) + doer.On("Do", mock.Anything).Return(resp, nil) + + input := AppVersionUploadURLInput{AppVersionID: "some-app-version-id"} + output, err := s.AppVersionUploadURL(context.TODO(), input) + + expected := AppVersionUploadURLOutput{} + if assert.ErrorContains(t, err, "expected error message") { + assert.Equal(t, expected, output) + } + }) +} diff --git a/cli/cmd/push/ignore.go b/cli/internal/archive/exclude.go similarity index 92% rename from cli/cmd/push/ignore.go rename to cli/internal/archive/exclude.go index 88a1ab3..557f0e4 100644 --- a/cli/cmd/push/ignore.go +++ b/cli/internal/archive/exclude.go @@ -1,4 +1,4 @@ -package push +package archive import ( "os" @@ -8,8 +8,8 @@ import ( const dblAsterisks = "**" -// Match matches patterns in the same manner that gitignore does. -func Match(pattern, value string) bool { +// match matches patterns in the same manner that gitignore does. +func match(pattern, value string) bool { // A blank line matches no files, so it can serve as a separator for readability. if pattern == "" { return false @@ -136,3 +136,14 @@ func matchFolder(pattern, value string) bool { } } } + +// Returns true if path matches any of the given exclude patterns. +func shouldExclude(excludedPatterns []string, path string) bool { + for _, pattern := range excludedPatterns { + if match(pattern, path) { + return true + } + } + + return false +} diff --git a/cli/cmd/push/zip_test.go b/cli/internal/archive/exclude_test.go similarity index 99% rename from cli/cmd/push/zip_test.go rename to cli/internal/archive/exclude_test.go index 932aa4c..ee764e1 100644 --- a/cli/cmd/push/zip_test.go +++ b/cli/internal/archive/exclude_test.go @@ -1,4 +1,4 @@ -package push +package archive import ( "fmt" diff --git a/cli/internal/archive/tar.go b/cli/internal/archive/tar.go new file mode 100644 index 0000000..f31db8e --- /dev/null +++ b/cli/internal/archive/tar.go @@ -0,0 +1,73 @@ +package archive + +import ( + "archive/tar" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// TarCreate creates a tar file at `destPath`, from the given `srcDir`, +// excluding files matching patterns in `exclude`. +func TarCreate(srcDir string, destPath string, exclude []string) error { + tarFile, err := os.Create(destPath) + if err != nil { + return err + } + + defer tarFile.Close() + + tw := tar.NewWriter(tarFile) + defer tw.Close() + + err = filepath.Walk(srcDir, func(fileName string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcDir, fileName) + if err != nil { + return err + } + + if shouldExclude(exclude, relPath) || tarFile.Name() == path.Join(srcDir, fi.Name()) { + return nil + } + + if relPath == "." { + return nil + } + + header, err := tar.FileInfoHeader(fi, fi.Name()) + if err != nil { + return err + } + header.Name = strings.ReplaceAll(relPath, "\\", "/") + + if err := tw.WriteHeader(header); err != nil { + return err + } + + // Ignore all non-regular files (e.g. directories, links, executables, etc.) + if !fi.Mode().IsRegular() { + return nil + } + + // Copy regular files + file, err := os.Open(fileName) + if err != nil { + return err + } + defer file.Close() + + if _, err := io.Copy(tw, file); err != nil { + return err + } + + return nil + }) + + return err +} diff --git a/cli/internal/archive/tar_test.go b/cli/internal/archive/tar_test.go new file mode 100644 index 0000000..28937c6 --- /dev/null +++ b/cli/internal/archive/tar_test.go @@ -0,0 +1,72 @@ +package archive + +import ( + "archive/tar" + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTarCreate(t *testing.T) { + t.Run("creates tar with all files", func(t *testing.T) { + tarDir := t.TempDir() + tarFilePath := tarDir + "/test.tar" + + err := TarCreate("testdata/testfolder/", tarFilePath, nil) + assert.NoError(t, err) + actual, err := readTarFile(tarFilePath) + assert.NoError(t, err) + + expected := readFiles(t, "testdata/testfolder") + assert.Equal(t, expected, actual) + }) + + t.Run("creates tar without ignored file path", func(t *testing.T) { + tarDir := t.TempDir() + tarFilePath := tarDir + "/test.tar" + + err := TarCreate("testdata/testfolder/", tarFilePath, []string{"dir/*"}) + assert.NoError(t, err) + actual, err := readTarFile(tarFilePath) + assert.NoError(t, err) + + expected := readFiles(t, "testdata/testfolder") + delete(expected, "dir/nested_file.txt") + assert.Equal(t, expected, actual) + }) +} + +func readTarFile(tarFilePath string) (map[string][]byte, error) { + result := make(map[string][]byte) + tarFile, err := os.Open(tarFilePath) + if err != nil { + return nil, err + } + tr := tar.NewReader(tarFile) + +ReadTar: + for { + var b []byte + + h, err := tr.Next() + + switch { + case (errors.Is(err, io.EOF)): + break ReadTar + case (err != nil): + return nil, err + case (h.Typeflag == tar.TypeDir): + continue + default: + if b, err = io.ReadAll(tr); err != nil { + return nil, err + } + result[h.Name] = b + } + } + + return result, nil +} diff --git a/cli/internal/archive/test_helpers.go b/cli/internal/archive/test_helpers.go new file mode 100644 index 0000000..6b18414 --- /dev/null +++ b/cli/internal/archive/test_helpers.go @@ -0,0 +1,33 @@ +package archive + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func readFiles(t *testing.T, root string) map[string][]byte { + t.Helper() + + files := make(map[string][]byte, 0) + err := filepath.Walk(root, func(path string, info fs.FileInfo, err error) error { + if info.IsDir() { + return err + } + + if relpath, err := filepath.Rel(root, path); !assert.NoError(t, err) { + return err + } else if data, err := os.ReadFile(path); assert.NoError(t, err) { + files[relpath] = data + return err + } + + return err + }) + assert.NoError(t, err) + + return files +} diff --git a/cli/internal/archive/testdata/testfolder.tar b/cli/internal/archive/testdata/testfolder.tar new file mode 100644 index 0000000..b48f3e2 Binary files /dev/null and b/cli/internal/archive/testdata/testfolder.tar differ diff --git a/cli/internal/archive/testdata/testfolder.zip b/cli/internal/archive/testdata/testfolder.zip new file mode 100644 index 0000000..103f67e Binary files /dev/null and b/cli/internal/archive/testdata/testfolder.zip differ diff --git a/cli/internal/archive/testdata/testfolder/dir/nested_file.txt b/cli/internal/archive/testdata/testfolder/dir/nested_file.txt new file mode 100644 index 0000000..e8003c8 --- /dev/null +++ b/cli/internal/archive/testdata/testfolder/dir/nested_file.txt @@ -0,0 +1 @@ +nested file data \ No newline at end of file diff --git a/cli/internal/archive/testdata/testfolder/file.txt b/cli/internal/archive/testdata/testfolder/file.txt new file mode 100644 index 0000000..0aa6fb5 --- /dev/null +++ b/cli/internal/archive/testdata/testfolder/file.txt @@ -0,0 +1 @@ +test data \ No newline at end of file diff --git a/cli/cmd/push/zip.go b/cli/internal/archive/zip.go similarity index 65% rename from cli/cmd/push/zip.go rename to cli/internal/archive/zip.go index c5a7a11..68787d5 100644 --- a/cli/cmd/push/zip.go +++ b/cli/internal/archive/zip.go @@ -1,4 +1,4 @@ -package push +package archive import ( "archive/zip" @@ -7,18 +7,28 @@ import ( "path/filepath" ) -// ZipFolder compresses the current directory into a zip-file. +// ZipCreate compresses the given source directory into a zip-file. // It returns an error if anything fails, else nil. -func ZipFolder(zipFile *os.File, exclude []string) error { +func ZipCreate(srcDir, destPath string, exclude []string) error { + zipFile, err := os.Create(destPath) + if err != nil { + return err + } + defer zipFile.Close() zipWriter := zip.NewWriter(zipFile) defer zipWriter.Close() - return filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - if shouldExclude(exclude, path) || info.Name() == zipFile.Name() { + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + if shouldExclude(exclude, relPath) || info.Name() == zipFile.Name() { return nil } @@ -28,10 +38,6 @@ func ZipFolder(zipFile *os.File, exclude []string) error { } // Ensure the header name is a relative path to avoid file path disclosure. - relPath, err := filepath.Rel(".", path) - if err != nil { - return err - } header.Name = relPath // Ensure folder names end with a slash to distinguish them in the zip. @@ -60,13 +66,3 @@ func ZipFolder(zipFile *os.File, exclude []string) error { return nil }) } - -func shouldExclude(excludedPatterns []string, path string) bool { - for _, pattern := range excludedPatterns { - if Match(pattern, path) { - return true - } - } - - return false -} diff --git a/cli/internal/archive/zip_test.go b/cli/internal/archive/zip_test.go new file mode 100644 index 0000000..7cecd9c --- /dev/null +++ b/cli/internal/archive/zip_test.go @@ -0,0 +1,69 @@ +package archive + +import ( + "archive/zip" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestZipCreate(t *testing.T) { + t.Run("creates zip with all files", func(t *testing.T) { + dir := t.TempDir() + path := dir + "/test.zip" + err := ZipCreate("testdata/testfolder/", path, nil) + assert.NoError(t, err) + actual, err := readZipFile(t, path) + assert.NoError(t, err) + + expected := readFiles(t, "testdata/testfolder") + assert.Equal(t, expected, actual) + }) + + t.Run("creates zip without ignored file path", func(t *testing.T) { + dir := t.TempDir() + path := dir + "/test.zip" + + err := ZipCreate("testdata/testfolder/", path, []string{"dir/*"}) + assert.NoError(t, err) + actual, err := readZipFile(t, path) + assert.NoError(t, err) + + expected := readFiles(t, "testdata/testfolder") + delete(expected, "dir/nested_file.txt") + assert.Equal(t, expected, actual) + }) +} + +func readZipFile(t *testing.T, path string) (map[string][]byte, error) { + t.Helper() + + stat, err := os.Stat(path) + require.NoError(t, err) + + result := make(map[string][]byte) + file, err := os.Open(path) + require.NoError(t, err) + + r, err := zip.NewReader(file, stat.Size()) + require.NoError(t, err) + + for _, file := range r.File { + if file.Mode().IsDir() { + continue + } + + f, err := r.Open(file.Name) + require.NoError(t, err) + + content, err := io.ReadAll(f) + require.NoError(t, err) + + result[file.Name] = content + } + + return result, nil +} diff --git a/cli/internal/gql/build/push.go b/cli/internal/gql/build/push.go index 091f246..2d5454b 100644 --- a/cli/internal/gql/build/push.go +++ b/cli/internal/gql/build/push.go @@ -2,9 +2,10 @@ package build import ( "context" - "encoding/base64" "os" + "numerous/cli/internal/gql/secret" + "git.sr.ht/~emersion/gqlclient" ) @@ -14,7 +15,7 @@ type pushResponse struct { func Push(file *os.File, appID string, client *gqlclient.Client, secrets map[string]string) (BuildConfiguration, error) { resp := pushResponse{} - convertedSecrets := appSecretsFromMap(secrets) + convertedSecrets := secret.AppSecretsFromMap(secrets) op := createBuildOperation(file, appID, convertedSecrets) @@ -25,30 +26,11 @@ func Push(file *os.File, appID string, client *gqlclient.Client, secrets map[str return resp.BuildPush, nil } -func appSecretsFromMap(secrets map[string]string) []*appSecret { - convertedSecrets := make([]*appSecret, 0) - - for name, value := range secrets { - secret := &appSecret{ - Name: name, - Base64Value: base64.StdEncoding.EncodeToString([]byte(value)), - } - convertedSecrets = append(convertedSecrets, secret) - } - - return convertedSecrets -} - -type appSecret struct { - Name string `json:"name"` - Base64Value string `json:"base64Value"` -} - type buildPushInput struct { - Secrets []*appSecret `json:"secrets"` + Secrets []*secret.AppSecret `json:"secrets"` } -func createBuildOperation(file *os.File, appID string, secrets []*appSecret) *gqlclient.Operation { +func createBuildOperation(file *os.File, appID string, secrets []*secret.AppSecret) *gqlclient.Operation { op := gqlclient.NewOperation(` mutation BuildPush($file: Upload!, $appID: ID!, $input: BuildPushInput!) { buildPush(file: $file, id: $appID, input: $input) { diff --git a/cli/internal/gql/build/push_test.go b/cli/internal/gql/build/push_test.go index 108c3eb..69f9c8f 100644 --- a/cli/internal/gql/build/push_test.go +++ b/cli/internal/gql/build/push_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "numerous/cli/internal/gql/secret" "numerous/cli/test" "github.com/stretchr/testify/assert" @@ -48,14 +49,14 @@ func TestAppSecretsFromMap(t *testing.T) { secretValue := "my secret value" secretName := "MY_SECRET" secrets := map[string]string{secretName: secretValue} - expected := []*appSecret{ + expected := []*secret.AppSecret{ { Name: secretName, Base64Value: base64.StdEncoding.EncodeToString([]byte(secretValue)), }, } - actual := appSecretsFromMap(secrets) + actual := secret.AppSecretsFromMap(secrets) assert.Equal(t, expected, actual) } diff --git a/cli/internal/gql/client.go b/cli/internal/gql/client.go index 738292f..fa9cce6 100644 --- a/cli/internal/gql/client.go +++ b/cli/internal/gql/client.go @@ -2,11 +2,13 @@ package gql import ( "net/http" + "os" "sync" "numerous/cli/auth" "git.sr.ht/~emersion/gqlclient" + "github.com/hasura/go-graphql-client" ) var ( @@ -48,3 +50,30 @@ func GetClient() *gqlclient.Client { once.Do(initClient) return client } + +func NewClient() *graphql.Client { + client := graphql.NewClient(httpURL, http.DefaultClient) + + accessToken := getAccessToken() + if accessToken != nil { + client = client.WithRequestModifier(func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+*accessToken) + }) + } + + return client +} + +func getAccessToken() *string { + token := os.Getenv("NUMEROUS_ACCESS_TOKEN") + if token != "" { + return &token + } + + user := auth.NumerousTenantAuthenticator.GetLoggedInUserFromKeyring() + if user != nil { + return &user.AccessToken + } + + return &token +} diff --git a/cli/internal/gql/secret/secret.go b/cli/internal/gql/secret/secret.go new file mode 100644 index 0000000..4704498 --- /dev/null +++ b/cli/internal/gql/secret/secret.go @@ -0,0 +1,22 @@ +package secret + +import "encoding/base64" + +func AppSecretsFromMap(secrets map[string]string) []*AppSecret { + convertedSecrets := make([]*AppSecret, 0) + + for name, value := range secrets { + secret := &AppSecret{ + Name: name, + Base64Value: base64.StdEncoding.EncodeToString([]byte(value)), + } + convertedSecrets = append(convertedSecrets, secret) + } + + return convertedSecrets +} + +type AppSecret struct { + Name string `json:"name"` + Base64Value string `json:"base64Value"` +} diff --git a/cli/internal/gql/subscription_client.go b/cli/internal/gql/subscription_client.go index 9e87e00..960f27a 100644 --- a/cli/internal/gql/subscription_client.go +++ b/cli/internal/gql/subscription_client.go @@ -3,18 +3,17 @@ package gql import ( "net/http" - "numerous/cli/auth" - "github.com/hasura/go-graphql-client" ) -func GetSubscriptionClient() *graphql.SubscriptionClient { +func NewSubscriptionClient() *graphql.SubscriptionClient { client := graphql.NewSubscriptionClient(wsURL) - if user := auth.NumerousTenantAuthenticator.GetLoggedInUserFromKeyring(); user != nil { + accessToken := getAccessToken() + if accessToken != nil { client = client.WithWebSocketOptions(graphql.WebsocketOptions{ HTTPHeader: http.Header{ - "Authorization": []string{"Bearer " + user.AccessToken}, + "Authorization": []string{"Bearer " + *accessToken}, }, }) } diff --git a/cli/test/gql_client.go b/cli/test/gql_client.go index 9f1f40f..876c706 100644 --- a/cli/test/gql_client.go +++ b/cli/test/gql_client.go @@ -8,7 +8,6 @@ import ( "git.sr.ht/~emersion/gqlclient" "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" _ "embed" ) @@ -16,7 +15,6 @@ import ( func CreateTestGqlClient(t *testing.T, response string) *gqlclient.Client { t.Helper() - schema := loadSchema(t) h := http.Header{} h.Add("Content-Type", "application/json") @@ -30,12 +28,7 @@ func CreateTestGqlClient(t *testing.T, response string) *gqlclient.Client { Response *http.Response Error error } { - query, err := readQuery(r) - require.NoError(t, err) - doc := parseQuery(t, string(query.query)) - query.updateDoc(t, &doc) - validateQuery(t, schema, doc) - + assertGraphQLRequest(t, r) return nil }, } diff --git a/cli/test/gqlvalidation.go b/cli/test/gqlvalidation.go index 2dc5d9d..1a9361b 100644 --- a/cli/test/gqlvalidation.go +++ b/cli/test/gqlvalidation.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/hasura/go-graphql-client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vektah/gqlparser/v2" @@ -23,6 +24,36 @@ import ( const schemaRelative = "/shared/schema.gql" +func assertGraphQLRequest(t *testing.T, r *http.Request) { + t.Helper() + + query, err := readQuery(r) + require.NoError(t, err) + + assertValidate(t, query) +} + +func assertValidate(t *testing.T, unparsed unparsedGraphqlQuery) { + t.Helper() + + schema := loadSchema(t) + doc := parseJSONQuery(t, string(unparsed.query)) + unparsed.updateDoc(t, &doc) + validateQuery(t, schema, doc) +} + +func assertSubscription(t *testing.T, v any, variables map[string]any) { + t.Helper() + + sub, _, err := graphql.ConstructSubscription(v, variables) + assert.NoError(t, err) + + parsed := parsedGraphQLQuery{Query: sub, Variables: variables} + schema := loadSchema(t) + parsed.doc = parseQuery(t, string(sub)) + validateQuery(t, schema, parsed) +} + func loadSchema(t *testing.T) *ast.Schema { t.Helper() @@ -71,7 +102,7 @@ type unparsedGraphqlQuery struct { fileContents map[string][]byte } -func (q unparsedGraphqlQuery) updateDoc(t *testing.T, doc *parsedGraphQLQuery) { +func (q unparsedGraphqlQuery) updateDoc(t *testing.T, parsed *parsedGraphQLQuery) { t.Helper() for filePartName, variablePath := range q.fileVariables { @@ -80,7 +111,7 @@ func (q unparsedGraphqlQuery) updateDoc(t *testing.T, doc *parsedGraphQLQuery) { varIdx := slices.Index(pathParts, "variables") require.Equal(t, 0, varIdx) // assume no reference to nested file variables - doc.Variables[pathParts[1]] = q.fileContents[filePartName] + parsed.Variables[pathParts[1]] = q.fileContents[filePartName] } } @@ -173,7 +204,7 @@ func validateQuery(t *testing.T, schema *ast.Schema, query parsedGraphQLQuery) { assert.NoError(t, err, "invalid variable values") } -func parseQuery(t *testing.T, query string) parsedGraphQLQuery { +func parseJSONQuery(t *testing.T, query string) parsedGraphQLQuery { t.Helper() var parsedQuery parsedGraphQLQuery @@ -181,13 +212,20 @@ func parseQuery(t *testing.T, query string) parsedGraphQLQuery { err := json.NewDecoder(strings.NewReader(query)).Decode(&parsedQuery) require.NoError(t, err, "error decoding query") - doc, err := parser.ParseQuery(&ast.Source{Input: parsedQuery.Query}) + doc := parseQuery(t, parsedQuery.Query) + parsedQuery.doc = doc + + return parsedQuery +} + +func parseQuery(t *testing.T, query string) *ast.QueryDocument { + t.Helper() + + doc, err := parser.ParseQuery(&ast.Source{Input: query}) if err != nil { _, ok := err.(*gqlerror.Error) require.False(t, ok, "error parsing query") } - parsedQuery.doc = doc - - return parsedQuery + return doc } diff --git a/cli/test/graphql.go b/cli/test/graphql.go new file mode 100644 index 0000000..e317d96 --- /dev/null +++ b/cli/test/graphql.go @@ -0,0 +1,111 @@ +package test + +import ( + "bytes" + "io" + "net/http" + "testing" + "time" + + "github.com/hasura/go-graphql-client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type validateDoer struct { + t *testing.T + inner graphql.Doer +} + +func (d *validateDoer) Do(r *http.Request) (*http.Response, error) { + resp, err := d.inner.Do(r) + if err != nil { + return resp, err + } + + data, err := io.ReadAll(resp.Body) + resp.Body = io.NopCloser(bytes.NewReader(data)) + assertGraphQLRequest(d.t, r) + + // recreate body + resp.Body = io.NopCloser(bytes.NewReader(data)) + + return resp, err +} + +func CreateTestGQLClient(t *testing.T, doer *MockDoer) *graphql.Client { + t.Helper() + + validateDoer := validateDoer{t, doer} + + return graphql.NewClient("http://url", &validateDoer) +} + +var _ graphql.Doer = &MockDoer{} + +type MockDoer struct { + mock.Mock +} + +func (m *MockDoer) Do(r *http.Request) (*http.Response, error) { + args := m.Called(r) + + return args.Get(0).(*http.Response), args.Error(1) +} + +func JSONResponse(json string) *http.Response { + return &http.Response{ + Status: "OK", + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(json))), + } +} + +type validatingSubscriptionClient struct { + t *testing.T + handler func(message []byte, err error) error + ch chan SubMessage + done chan struct{} +} + +func (c *validatingSubscriptionClient) Run() error { + go func() { + for ev := range c.ch { + c.handler([]byte(ev.Msg), ev.Err) //nolint:errcheck + } + close(c.done) + }() + + return nil +} + +func (c *validatingSubscriptionClient) Subscribe(v interface{}, variables map[string]interface{}, handler func(message []byte, err error) error, options ...graphql.Option) (string, error) { + assertSubscription(c.t, v, variables) + c.handler = handler + + return "subID", nil +} + +func (c *validatingSubscriptionClient) Close() error { return nil } + +func (c *validatingSubscriptionClient) Wait() { + c.t.Helper() + + select { + case <-c.done: + return + case <-time.After(time.Second): + assert.Fail(c.t, "timed out waiting for subscription to close") + } +} + +type SubMessage struct { + Msg string + Err error +} + +func CreateTestSubscriptionClient(t *testing.T, ch chan SubMessage) *validatingSubscriptionClient { + t.Helper() + + return &validatingSubscriptionClient{t, nil, ch, make(chan struct{})} +} diff --git a/cli/testdata/streamlit_app/app.py b/cli/testdata/streamlit_app/app.py new file mode 100644 index 0000000..822adda --- /dev/null +++ b/cli/testdata/streamlit_app/app.py @@ -0,0 +1,10 @@ +import streamlit as st + +st.title("Counter Example") +count = 0 + +increment = st.button("Increment") +if increment: + count += 1 + +st.write("Count = ", count) diff --git a/cli/testdata/streamlit_app/app_cover.jpg b/cli/testdata/streamlit_app/app_cover.jpg new file mode 100644 index 0000000..d86fbc9 Binary files /dev/null and b/cli/testdata/streamlit_app/app_cover.jpg differ diff --git a/cli/testdata/streamlit_app/numerous.toml b/cli/testdata/streamlit_app/numerous.toml new file mode 100644 index 0000000..22801dc --- /dev/null +++ b/cli/testdata/streamlit_app/numerous.toml @@ -0,0 +1,9 @@ +name = "streamlit_example" +description = "" +library = "streamlit" +python = "3.11" +app_file = "app.py" +requirements_file = "requirements.txt" +port = 80 +cover_image = "app_cover.jpg" +exclude = ["*venv", "venv*", ".git"] diff --git a/cli/testdata/streamlit_app/requirements.txt b/cli/testdata/streamlit_app/requirements.txt new file mode 100644 index 0000000..12a4706 --- /dev/null +++ b/cli/testdata/streamlit_app/requirements.txt @@ -0,0 +1 @@ +streamlit diff --git a/cli/tool/tool.go b/cli/tool/tool.go index e82df2f..b2732aa 100644 --- a/cli/tool/tool.go +++ b/cli/tool/tool.go @@ -70,7 +70,7 @@ func ReadAppID(basePath string) (string, error) { func ReadAppIDAndPrintErrors(appDir string) (string, error) { appID, err := ReadAppID(appDir) if err == ErrAppIDNotFound { - output.PrintErrorAppNotInitialized() + output.PrintErrorAppNotInitialized(appDir) return "", err } else if err != nil { output.PrintErrorDetails("An error occurred reading the app ID", err) diff --git a/pyproject.toml b/pyproject.toml index d5955f7..ca14476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ exclude = [ "./setup.py", "./python/docs", "./examples", + "./cli/**", ] [tool.ruff.lint] diff --git a/python/src/numerous/generated/graphql/input_types.py b/python/src/numerous/generated/graphql/input_types.py index f25d994..d7168db 100644 --- a/python/src/numerous/generated/graphql/input_types.py +++ b/python/src/numerous/generated/graphql/input_types.py @@ -31,6 +31,7 @@ class AppCreateInfo(BaseModel): class AppDeployInput(BaseModel): + app_relative_path: Optional[str] = Field(alias="appRelativePath", default=None) secrets: Optional[List["AppSecret"]] = None diff --git a/shared/schema.gql b/shared/schema.gql index c08ddf3..f794be7 100644 --- a/shared/schema.gql +++ b/shared/schema.gql @@ -296,6 +296,7 @@ input AppCreateInfo { } input AppDeployInput { + appRelativePath: String secrets: [AppSecret!] } @@ -303,11 +304,15 @@ type AppBuildMessageEvent { message: String! } +type AppBuildErrorEvent { + message: String! +} + type AppDeploymentStatusEvent { status: AppDeploymentStatus } -union AppDeployEvent = AppBuildMessageEvent | AppDeploymentStatusEvent +union AppDeployEvent = AppBuildMessageEvent | AppDeploymentStatusEvent | AppBuildErrorEvent extend type Query { app(organizationSlug: String!, appName: String!): App @hasRole(role: USER)