Skip to content

Commit

Permalink
add a wait flag to deploys create (#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
kitemongerer authored Dec 3, 2024
1 parent 1473812 commit d92d330
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 16 deletions.
55 changes: 48 additions & 7 deletions cmd/deploycreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package cmd
import (
"context"
"fmt"
"os"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"

"github.com/renderinc/cli/pkg/client"
"github.com/renderinc/cli/pkg/command"
"github.com/renderinc/cli/pkg/deploy"
"github.com/renderinc/cli/pkg/resource"
"github.com/renderinc/cli/pkg/text"
"github.com/renderinc/cli/pkg/tui/views"
Expand Down Expand Up @@ -46,13 +48,15 @@ func init() {
return fmt.Errorf("failed to parse command: %w", err)
}

if nonInteractive, err := command.NonInteractiveWithConfirm(cmd, func() (*client.Deploy, error) {
return views.CreateDeploy(cmd.Context(), input)
}, func(deploy *client.Deploy) string {
return text.FormatStringF("Created deploy %s for service %s", deploy.Id, input.ServiceID)
}, views.DeployCreateConfirm(cmd.Context(), input)); err != nil {
return err
} else if nonInteractive {
// if wait flag is used, default to non-interactive output
outputFormat := command.GetFormatFromContext(cmd.Context())
if input.Wait && outputFormat.Interactive() {
output := command.TEXT
cmd.SetContext(command.SetFormatInContext(cmd.Context(), &output))
}

nonInteractive := nonInteractiveDeployCreate(cmd, input)
if nonInteractive {
return nil
}

Expand All @@ -68,7 +72,44 @@ func init() {
deployCreateCmd.Flags().Bool("clear-cache", false, "Clear build cache before deploying")
deployCreateCmd.Flags().String("commit", "", "The commit ID to deploy")
deployCreateCmd.Flags().String("image", "", "The Docker image URL to deploy")
deployCreateCmd.Flags().Bool("wait", false, "Wait for deploy to finish. Returns non-zero exit code if deploy fails")

deployCmd.AddCommand(deployCreateCmd)
rootCmd.AddCommand(deployCmd)
}

func nonInteractiveDeployCreate(cmd *cobra.Command, input types.DeployInput) bool {
var dep *client.Deploy
createDeploy := func() (*client.Deploy, error) {
d, err := views.CreateDeploy(cmd.Context(), input)
if err != nil {
return nil, err
}

if input.Wait {
_, err = fmt.Fprintf(cmd.OutOrStderr(), "Waiting for deploy %s to complete...\n\n", d.Id)
if err != nil {
return nil, err
}
dep, err = views.WaitForDeploy(cmd.Context(), input.ServiceID, d.Id)
return dep, err
}

return d, err
}

nonInteractive, err := command.NonInteractiveWithConfirm(cmd, createDeploy, text.Deploy(input.ServiceID), views.DeployCreateConfirm(cmd.Context(), input))
if err != nil {
_, err = fmt.Fprintf(cmd.OutOrStderr(), err.Error()+"\n")
os.Exit(1)
}
if !nonInteractive {
return false
}

if input.Wait && !deploy.IsSuccessful(dep.Status) {
os.Exit(1)
}

return nonInteractive
}
6 changes: 3 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ var rootCmd = &cobra.Command{
os.Exit(1)
}
// Honor the output flag if it's set
if outputFlag == "" && output == command.Interactive && (isPipe() || isCI()) {
if outputFlag == "" && output.Interactive() && (isPipe() || isCI()) {
output = command.TEXT
}
ctx = command.SetFormatInContext(ctx, &output)

if output == command.Interactive {
if output.Interactive() {
stack := tui.NewStack()

ctx = tui.SetStackInContext(ctx, stack)
Expand All @@ -88,7 +88,7 @@ var rootCmd = &cobra.Command{
ctx := cmd.Context()

output := command.GetFormatFromContext(ctx)
if output == nil || *output == command.Interactive {
if output.Interactive() {
stack := tui.GetStackFromContext(ctx)
if stack == nil {
return nil
Expand Down
13 changes: 13 additions & 0 deletions pkg/deploy/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ func (d *Repo) TriggerDeploy(ctx context.Context, serviceID string, input Trigge
return resp.JSON201, nil
}

func (d *Repo) GetDeploy(ctx context.Context, serviceID, deployID string) (*client.Deploy, error) {
resp, err := d.client.RetrieveDeployWithResponse(ctx, serviceID, deployID)
if err != nil {
return nil, err
}

if err := client.ErrorFromResponse(resp); err != nil {
return nil, err
}

return resp.JSON200, nil
}

func (d *Repo) CancelDeploy(ctx context.Context, serviceID, deployID string) (*client.Deploy, error) {
resp, err := d.client.CancelDeployWithResponse(ctx, serviceID, deployID)
if err != nil {
Expand Down
30 changes: 29 additions & 1 deletion pkg/deploy/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,36 @@ var cancellableStatuses = []client.DeployStatus{
client.DeployStatusPreDeployInProgress,
}


func IsCancellable(status *client.DeployStatus) bool {
return status == nil || slices.Contains(cancellableStatuses, *status)
}

func IsComplete(status *client.DeployStatus) bool {
if status == nil {
return false
}
switch *status {
case client.DeployStatusBuildFailed,
client.DeployStatusCanceled,
client.DeployStatusDeactivated,
client.DeployStatusLive,
client.DeployStatusPreDeployFailed,
client.DeployStatusUpdateFailed:
return true
case client.DeployStatusBuildInProgress,
client.DeployStatusCreated,
client.DeployStatusPreDeployInProgress,
client.DeployStatusUpdateInProgress:
return false
default:
return false
}
}

func IsSuccessful(status *client.DeployStatus) bool {
if status == nil {
return false
}

return *status == client.DeployStatusLive || *status == client.DeployStatusDeactivated
}
36 changes: 36 additions & 0 deletions pkg/deploy/util_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package deploy_test

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/renderinc/cli/pkg/client"
"github.com/renderinc/cli/pkg/deploy"
)

func TestIsComplete(t *testing.T) {
t.Run("handles nil status", func(t *testing.T) {
assert.False(t, deploy.IsComplete(nil))
})

tests := map[client.DeployStatus]bool{
client.DeployStatusBuildFailed: true,
client.DeployStatusBuildInProgress: false,
client.DeployStatusCanceled: true,
client.DeployStatusCreated: false,
client.DeployStatusDeactivated: true,
client.DeployStatusLive: true,
client.DeployStatusPreDeployFailed: true,
client.DeployStatusPreDeployInProgress: false,
client.DeployStatusUpdateFailed: true,
client.DeployStatusUpdateInProgress: false,
}

for status, expected := range tests {
t.Run(string(status), func(t *testing.T) {
actual := deploy.IsComplete(&status)
assert.Equal(t, expected, actual)
})
}
}
26 changes: 25 additions & 1 deletion pkg/text/string.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package text

import "fmt"
import (
"fmt"

"github.com/renderinc/cli/pkg/client"
"github.com/renderinc/cli/pkg/deploy"
)

func FormatString(s string) string {
return FormatStringF(s)
Expand All @@ -9,3 +14,22 @@ func FormatString(s string) string {
func FormatStringF(s string, a ...any) string {
return fmt.Sprintf(s+"\n", a...)
}

func Deploy(serviceID string) func(dep *client.Deploy) string {
return func(dep *client.Deploy) string {
if deploy.IsSuccessful(dep.Status) {
return FormatStringF("Deploy %s succeeded for service %s", dep.Id, serviceID)
} else if deploy.IsComplete(dep.Status) {
switch *dep.Status {
case client.DeployStatusBuildFailed:
return FormatStringF("Build failed for deploy %s", dep.Id)
case client.DeployStatusPreDeployFailed:
return FormatStringF("Pre Deploy failed for deploy %s", dep.Id)
default:
return FormatStringF("Deploy %s failed for service %s", dep.Id, serviceID)
}
}

return FormatStringF("Created deploy %s for service %s", dep.Id, serviceID)
}
}
52 changes: 48 additions & 4 deletions pkg/tui/views/deploycreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package views
import (
"context"
"fmt"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"

"github.com/renderinc/cli/pkg/client"
"github.com/renderinc/cli/pkg/command"
"github.com/renderinc/cli/pkg/deploy"
Expand All @@ -15,14 +17,14 @@ import (
"github.com/renderinc/cli/pkg/types"
)

const deployTimeout = time.Hour

func CreateDeploy(ctx context.Context, input types.DeployInput) (*client.Deploy, error) {
c, err := client.NewDefaultClient()
deployRepo, err := newDeployRepo()
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
return nil, err
}

deployRepo := deploy.NewRepo(c)

if input.CommitID != nil && *input.CommitID == "" {
input.CommitID = nil
}
Expand All @@ -43,6 +45,48 @@ func CreateDeploy(ctx context.Context, input types.DeployInput) (*client.Deploy,
return d, nil
}

func newDeployRepo() (*deploy.Repo, error) {
c, err := client.NewDefaultClient()
if err != nil {
return nil, fmt.Errorf("failed to create client: %w", err)
}

deployRepo := deploy.NewRepo(c)
return deployRepo, nil
}

func WaitForDeploy(ctx context.Context, serviceID, deployID string) (*client.Deploy, error) {
deployRepo, err := newDeployRepo()
if err != nil {
return nil, err
}

timeoutTimer := time.NewTimer(deployTimeout)

for {
select {
case <-timeoutTimer.C:
return nil, fmt.Errorf("timed out waiting for deploy to finish")
default:
d, err := deployRepo.GetDeploy(ctx, serviceID, deployID)
if err != nil {
return nil, err
}

if deploy.IsComplete(d.Status) {
return d, nil
}

if d.Status == nil || *d.Status == client.DeployStatusCreated {
time.Sleep(10 * time.Second)
} else {
// if the deploy has started, poll more frequently
time.Sleep(5 * time.Second)
}
}
}
}

type DeployCreateView struct {
formAction *tui.FormWithAction[*client.Deploy]

Expand Down
64 changes: 64 additions & 0 deletions pkg/tui/views/deploycreate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package views_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/renderinc/cli/pkg/client"
"github.com/renderinc/cli/pkg/tui/views"
)

func TestWaitForDeploy(t *testing.T) {
getDeployCallCount := 0
setupTestServer(t, func() (int, string) {
getDeployCallCount++
if getDeployCallCount == 1 {
return http.StatusOK, fmt.Sprintf(deployRespTmpl, client.DeployStatusBuildInProgress)
}

return http.StatusOK, fmt.Sprintf(deployRespTmpl, client.DeployStatusLive)
})

dep, err := views.WaitForDeploy(context.Background(), "some-service-id", "some-deploy-id")
require.NoError(t, err)

assert.Equal(t, client.DeployStatusLive, *dep.Status)
assert.Equal(t, "some-deploy-id", dep.Id)
}

func setupTestServer(t *testing.T, handler func() (int, string)) *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")

code, resp := handler()
w.WriteHeader(code)
_, err := w.Write([]byte(resp))
require.NoError(t, err)
}))

require.NoError(t, os.Setenv("RENDER_API_KEY", "test-key"))
require.NoError(t, os.Setenv("RENDER_HOST", s.URL))

return s
}

const deployRespTmpl = `{
"commit": {
"createdAt": "2022-09-23T15:34:12Z",
"id": "a21fb02cd25b7be602c5becf7fcbe6cdb9764db8",
"message": "Merge pull request #3"
},
"createdAt": "2024-12-03T17:02:30.548731Z",
"finishedAt": "2024-12-03T17:04:01.515412Z",
"id": "some-deploy-id",
"status": "%s",
"trigger": "api",
"updatedAt": "2024-12-03T17:04:01.516462Z"
}`
1 change: 1 addition & 0 deletions pkg/types/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type DeployInput struct {
ClearCache bool `cli:"clear-cache"`
CommitID *string `cli:"commit"`
ImageURL *string `cli:"image"`
Wait bool `cli:"wait"`
}

func (d DeployInput) String() []string {
Expand Down

0 comments on commit d92d330

Please sign in to comment.