Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
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