Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): deploy command #4

Merged
merged 39 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
507892d
add `deploy` command skeleton
jfeo May 27, 2024
07dbcd3
implement wrapper for graphql app create mutation, and add test funct…
jfeo May 27, 2024
7eb73ad
review comments, cmd/deploy package, help text
jfeo May 27, 2024
d57b6a4
app version create mutation, and renaming
jfeo May 27, 2024
3739cb7
app version upload url mutation
jfeo May 27, 2024
f6f7c16
create archive package, refactor zip archive logic, add tar logic
jfeo May 27, 2024
2bbb910
add exclude logic to create tar function
jfeo May 27, 2024
f7e96d7
test & refactor zip creation, refactor exclude
jfeo May 27, 2024
f164f6f
remove unused code, add docs to function
jfeo May 27, 2024
527eb55
implement deploy command setup, wrap app logic in service, require au…
jfeo May 27, 2024
0f0877b
fix deploy
jfeo May 27, 2024
bc54679
update deploy organization flag help text
jfeo May 28, 2024
22e8a52
refactor: simplify by moving deploy code in numerous/cli/app into num…
jfeo May 28, 2024
ca1da40
given/then test names
jfeo May 28, 2024
f8d17a8
add app deploy mutation wrapper
jfeo May 28, 2024
0451e6c
use deploy app in command
jfeo May 28, 2024
3034251
deploy events
jfeo May 28, 2024
66c71e2
update deploy events and use in command
jfeo May 28, 2024
8bd952b
check if app exists before creating
jfeo May 28, 2024
168fa1e
prettier output
jfeo May 28, 2024
612ae28
prettier task status output
jfeo May 28, 2024
8507604
make cli task output easier to use
jfeo May 28, 2024
f03691a
prettier output
jfeo May 28, 2024
db91ea8
remove failure condition added for debugging
jfeo May 28, 2024
0174b84
simplify testdata .gitignore
jfeo May 28, 2024
29e6b3e
add secrets to deploy
jfeo Jun 3, 2024
003f846
wip: verbose
jfeo Jun 6, 2024
edc4f77
add deployment events
jfeo Jun 6, 2024
d205a10
add long description and positional argument in usage
jfeo Jun 7, 2024
219ec4a
support project and app directory arguments
jfeo Jun 7, 2024
3f23126
handle app not found properly
jfeo Jun 7, 2024
f730f41
better error output formatting for read app errors
jfeo Jun 7, 2024
484aad9
handle build error events
jfeo Jun 10, 2024
0b483a2
avoid extra newlines in verbose build output
jfeo Jun 10, 2024
05de2a4
fix content length in upload request
jfeo Jun 10, 2024
8af23de
fix: upload app source buffer
moroderNumerous Jun 10, 2024
e964459
fix content-length header
jfeo Jun 10, 2024
f78c69c
Merge deployment status cases
jfeodor Jun 10, 2024
3b274c0
review changes
jfeo Jun 10, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions cli/cmd/deploy/cmd.go
Original file line number Diff line number Diff line change
@@ -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 <name> and <organization> identifier.
Deploying an app to a given <name> and <organization> combination, will override
the existing version.

The <name> 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 {
jfeodor marked this conversation as resolved.
Show resolved Hide resolved
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())
}
}
201 changes: 201 additions & 0 deletions cli/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
@@ -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)
jfeodor marked this conversation as resolved.
Show resolved Hide resolved
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
}
Loading