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