Skip to content

Commit

Permalink
APPS-9859 Add doctl apps console command to start a console session t…
Browse files Browse the repository at this point in the history
…o a component (digitalocean#1612)

* Add exec command

* update Listener to accept inputs

* move input logic to RunAppsGetExec

* listen for resize events using SIGWINCH

* rename exec command to console

* refactor

* add test

* move stdin to errgroup goroutine

* add ReadRawStdin to Doit

* fix test

* make stdinCh a `chan<- []byte`

* fix keystroke drops

* cleanup

* add test

* bump godo version

* run go mod vendor

* fix test

* fix integration test for logs

* move MonitorResizeEvents and ReadRawStdin to terminal package

* make terminal mockable by making an interface

* move `term.ReadRawStdin` out of errgroup to improve user experience

This also removes the "Press any key to exit." prompt, which I felt was annoying.

* fix error message typos
  • Loading branch information
blesswinsamuel authored Nov 18, 2024
1 parent fbf093f commit bd65bcc
Show file tree
Hide file tree
Showing 34 changed files with 1,126 additions and 133 deletions.
134 changes: 132 additions & 2 deletions commands/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,26 @@ package commands
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"time"

"github.com/digitalocean/doctl"
"github.com/digitalocean/doctl/commands/displayers"
"github.com/digitalocean/doctl/do"
"github.com/digitalocean/doctl/internal/apps"
"github.com/digitalocean/doctl/pkg/terminal"
"github.com/digitalocean/godo"
multierror "github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
"sigs.k8s.io/yaml"
)

Expand Down Expand Up @@ -191,6 +195,19 @@ For more information about logs, see [How to View Logs](https://www.digitalocean

logs.Example = `The following example retrieves the build logs for the app with the ID ` + "`" + `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` + "`" + ` and the component ` + "`" + `web` + "`" + `: doctl apps logs f81d4fae-7dec-11d0-a765-00a0c91e6bf6 web --type build`

console := CmdBuilder(
cmd,
RunAppsConsole,
"console <app id> <component name>",
"Starts a console session",
`Instantiates a console session for a component of an app.`,
Writer,
aliasOpt("l"),
)
AddStringFlag(console, doctl.ArgAppDeployment, "", "", "Starts a console session for a specific deployment ID. Defaults to current deployment.")

console.Example = `The following example initiates a console session for the app with the ID ` + "`" + `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` + "`" + ` and the component ` + "`" + `web` + "`" + `: doctl apps console f81d4fae-7dec-11d0-a765-00a0c91e6bf6 web`

listRegions := CmdBuilder(
cmd,
RunAppsListRegions,
Expand Down Expand Up @@ -662,8 +679,10 @@ func RunAppsGetLogs(c *CmdConfig) error {
url.Scheme = "wss"
}

listener := c.Doit.Listen(url, token, schemaFunc, c.Out)
err = listener.Start()
listener := c.Doit.Listen(url, token, schemaFunc, c.Out, nil)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
err = listener.Listen(ctx)
if err != nil {
return err
}
Expand Down Expand Up @@ -695,6 +714,117 @@ func RunAppsGetLogs(c *CmdConfig) error {
return nil
}

// RunAppsConsole initiates a console session for an app.
func RunAppsConsole(c *CmdConfig) error {
if len(c.Args) < 2 {
return doctl.NewMissingArgsErr(c.NS)
}
appID := c.Args[0]
component := c.Args[1]

deploymentID, err := c.Doit.GetString(c.NS, doctl.ArgAppDeployment)
if err != nil {
return err
}

execResp, err := c.Apps().GetExec(appID, deploymentID, component)
if err != nil {
return err
}
url, err := url.Parse(execResp.URL)
if err != nil {
return err
}
token := url.Query().Get("token")

schemaFunc := func(message []byte) (io.Reader, error) {
data := struct {
Data string `json:"data"`
}{}
err = json.Unmarshal(message, &data)
if err != nil {
return nil, err
}
r := strings.NewReader(data.Data)
return r, nil
}

inputCh := make(chan []byte)

listener := c.Doit.Listen(url, token, schemaFunc, c.Out, inputCh)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

grp, ctx := errgroup.WithContext(ctx)

term := c.Doit.Terminal()
stdinCh := make(chan string)
restoreTerminal, err := term.ReadRawStdin(ctx, stdinCh)
if err != nil {
return err
}
defer restoreTerminal()

resizeEvents := make(chan terminal.TerminalSize)
grp.Go(func() error {
return term.MonitorResizeEvents(ctx, resizeEvents)
})

grp.Go(func() error {
keepaliveTicker := time.NewTicker(30 * time.Second)
defer keepaliveTicker.Stop()
type stdinOp struct {
Op string `json:"op"`
Data string `json:"data"`
}
type resizeOp struct {
Op string `json:"op"`
Width int `json:"width"`
Height int `json:"height"`
}
for {
select {
case <-ctx.Done():
return nil
case in := <-stdinCh:
b, err := json.Marshal(stdinOp{Op: "stdin", Data: in})
if err != nil {
return fmt.Errorf("error encoding stdin: %v", err)
}
inputCh <- b
case <-keepaliveTicker.C:
b, err := json.Marshal(stdinOp{Op: "stdin", Data: ""})
if err != nil {
return fmt.Errorf("error encoding keepalive event: %v", err)
}
inputCh <- b
case ev := <-resizeEvents:
b, err := json.Marshal(resizeOp{Op: "resize", Width: ev.Width, Height: ev.Height})
if err != nil {
return fmt.Errorf("error encoding resize event: %v", err)
}
inputCh <- b
}
}
})

grp.Go(func() error {
err = listener.Listen(ctx)
if err != nil {
return err
}
cancel() // cancel the context to stop the other goroutines
return nil
})

if err := grp.Wait(); err != nil {
return err
}

return nil
}

// RunAppsPropose proposes an app spec
func RunAppsPropose(c *CmdConfig) error {
appID, err := c.Doit.GetString(c.NS, doctl.ArgApp)
Expand Down
36 changes: 34 additions & 2 deletions commands/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ import (

"github.com/digitalocean/doctl"
"github.com/digitalocean/doctl/pkg/listen"
"github.com/digitalocean/doctl/pkg/terminal"
"github.com/digitalocean/godo"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)

func TestAppsCommand(t *testing.T) {
cmd := Apps()
require.NotNil(t, cmd)
assertCommandNames(t, cmd,
"console",
"create",
"get",
"list",
Expand Down Expand Up @@ -431,10 +434,10 @@ func TestRunAppsGetLogs(t *testing.T) {
for typeStr, logType := range types {
withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.apps.EXPECT().GetLogs(appID, deploymentID, component, logType, true, 1).Times(1).Return(&godo.AppLogs{LiveURL: "https://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33"}, nil)
tm.listen.EXPECT().Start().Times(1).Return(nil)
tm.listen.EXPECT().Listen(gomock.Any()).Times(1).Return(nil)

tc := config.Doit.(*doctl.TestConfig)
tc.ListenFn = func(url *url.URL, token string, schemaFunc listen.SchemaFunc, out io.Writer) listen.ListenerService {
tc.ListenFn = func(url *url.URL, token string, schemaFunc listen.SchemaFunc, out io.Writer, in <-chan []byte) listen.ListenerService {
assert.Equal(t, "aa-bb-11-cc-33", token)
assert.Equal(t, "wss://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33", url.String())
return tm.listen
Expand All @@ -452,6 +455,35 @@ func TestRunAppsGetLogs(t *testing.T) {
}
}

func TestRunAppsConsole(t *testing.T) {
appID := uuid.New().String()
deploymentID := uuid.New().String()
component := "service"

withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
tm.apps.EXPECT().GetExec(appID, deploymentID, component).Times(1).Return(&godo.AppExec{URL: "wss://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33"}, nil)
tm.listen.EXPECT().Listen(gomock.Any()).Times(1).Return(nil)
tm.terminal.EXPECT().ReadRawStdin(gomock.Any(), gomock.Any()).Times(1).Return(func() {}, nil)
tm.terminal.EXPECT().MonitorResizeEvents(gomock.Any(), gomock.Any()).Times(1).Return(nil)

tc := config.Doit.(*doctl.TestConfig)
tc.ListenFn = func(url *url.URL, token string, schemaFunc listen.SchemaFunc, out io.Writer, in <-chan []byte) listen.ListenerService {
assert.Equal(t, "aa-bb-11-cc-33", token)
assert.Equal(t, "wss://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33", url.String())
return tm.listen
}
tc.TerminalFn = func() terminal.Terminal {
return tm.terminal
}

config.Args = append(config.Args, appID, component)
config.Doit.Set(config.NS, doctl.ArgAppDeployment, deploymentID)

err := RunAppsConsole(config)
require.NoError(t, err)
})
}

const (
validJSONSpec = `{
"name": "test",
Expand Down
2 changes: 2 additions & 0 deletions commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ type tcMocks struct {
vpcs *domocks.MockVPCsService
oneClick *domocks.MockOneClickService
listen *domocks.MockListenerService
terminal *domocks.MockTerminal
monitoring *domocks.MockMonitoringService
serverless *domocks.MockServerlessService
appBuilderFactory *builder.MockComponentBuilderFactory
Expand Down Expand Up @@ -226,6 +227,7 @@ func withTestClient(t *testing.T, tFn testFn) {
vpcs: domocks.NewMockVPCsService(ctrl),
oneClick: domocks.NewMockOneClickService(ctrl),
listen: domocks.NewMockListenerService(ctrl),
terminal: domocks.NewMockTerminal(ctrl),
monitoring: domocks.NewMockMonitoringService(ctrl),
serverless: domocks.NewMockServerlessService(ctrl),
appBuilderFactory: builder.NewMockComponentBuilderFactory(ctrl),
Expand Down
9 changes: 9 additions & 0 deletions do/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type AppsService interface {
ListDeployments(appID string) ([]*godo.Deployment, error)

GetLogs(appID, deploymentID, component string, logType godo.AppLogType, follow bool, tail int) (*godo.AppLogs, error)
GetExec(appID, deploymentID, component string) (*godo.AppExec, error)

ListRegions() ([]*godo.AppRegion, error)

Expand Down Expand Up @@ -186,6 +187,14 @@ func (s *appsService) GetLogs(appID, deploymentID, component string, logType god
return logs, nil
}

func (s *appsService) GetExec(appID, deploymentID, component string) (*godo.AppExec, error) {
exec, _, err := s.client.Apps.GetExec(s.ctx, appID, deploymentID, component)
if err != nil {
return nil, err
}
return exec, nil
}

func (s *appsService) ListRegions() ([]*godo.AppRegion, error) {
regions, _, err := s.client.Apps.ListRegions(s.ctx)
if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions do/mocks/AppsService.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 7 additions & 18 deletions do/mocks/Listen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit bd65bcc

Please sign in to comment.