From f489183ad3ca61bd21203ce9a1ec6f7a7b1645cc Mon Sep 17 00:00:00 2001 From: Ivan Velichko Date: Wed, 6 Mar 2024 20:53:31 +0000 Subject: [PATCH] add playground mgmt commands --- cmd/auth/whoami.go | 2 +- cmd/playground/list.go | 149 +++++++++++++++++++++++++++++++++++ cmd/playground/playground.go | 23 ++++++ cmd/playground/start.go | 73 +++++++++++++++++ cmd/playground/stop.go | 80 +++++++++++++++++++ go.mod | 1 + go.sum | 2 + internal/api/account.go | 2 +- internal/api/plays.go | 47 ++++++++--- main.go | 2 + 10 files changed, 370 insertions(+), 11 deletions(-) create mode 100644 cmd/playground/list.go create mode 100644 cmd/playground/playground.go create mode 100644 cmd/playground/start.go create mode 100644 cmd/playground/stop.go diff --git a/cmd/auth/whoami.go b/cmd/auth/whoami.go index d5aec80..86c1b38 100644 --- a/cmd/auth/whoami.go +++ b/cmd/auth/whoami.go @@ -26,7 +26,7 @@ func runWhoAmI(ctx context.Context, cli labcli.CLI) error { return nil } - me, err := cli.Client().GetMe(ctx) + me, err := cli.Client().GetAccount(ctx) if err != nil { return err } diff --git a/cmd/playground/list.go b/cmd/playground/list.go new file mode 100644 index 0000000..c2c0768 --- /dev/null +++ b/cmd/playground/list.go @@ -0,0 +1,149 @@ +package playground + +import ( + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + + "github.com/dustin/go-humanize" + "github.com/spf13/cobra" + + "github.com/iximiuz/labctl/internal/api" + "github.com/iximiuz/labctl/internal/labcli" +) + +type listOptions struct { + all bool + quiet bool +} + +func newListCommand(cli labcli.CLI) *cobra.Command { + var opts listOptions + + cmd := &cobra.Command{ + Use: "list [flags]", + Aliases: []string{"ls"}, + Short: `List recent playgrounds (up to 50)`, + RunE: func(cmd *cobra.Command, args []string) error { + return labcli.WrapStatusError(runListPlaygrounds(cmd.Context(), cli, &opts)) + }, + } + + flags := cmd.Flags() + + flags.BoolVarP( + &opts.all, + "all", + "a", + false, + `List all playgrounds (including terminated)`, + ) + flags.BoolVarP( + &opts.quiet, + "quiet", + "q", + false, + `Only print playground IDs`, + ) + + return cmd +} + +func runListPlaygrounds(ctx context.Context, cli labcli.CLI, opts *listOptions) error { + printer := newListPrinter(cli.OutputStream(), opts.quiet) + defer printer.flush() + + printer.printHeader() + + plays, err := cli.Client().ListPlays(ctx) + if err != nil { + return fmt.Errorf("couldn't list playgrounds: %w", err) + } + + for _, play := range plays { + if opts.all || play.Active { + printer.printOne(play) + } + } + + return nil +} + +type listPrinter struct { + quiet bool + header []string + writer *tabwriter.Writer +} + +func newListPrinter(outStream io.Writer, quiet bool) *listPrinter { + header := []string{ + "PLAYGROUND ID", + "NAME", + "CREATED", + "STATUS", + "LINK", + } + + return &listPrinter{ + quiet: quiet, + header: header, + writer: tabwriter.NewWriter(outStream, 0, 4, 2, ' ', 0), + } +} + +func (p *listPrinter) printHeader() { + if !p.quiet { + fmt.Fprintln(p.writer, strings.Join(p.header, "\t")) + } +} + +func (p *listPrinter) printOne(play *api.Play) { + if p.quiet { + fmt.Fprintln(p.writer, play.ID) + return + } + + var link string + if play.Active { + link = play.PageURL + } + + fields := []string{ + play.ID, + play.Playground.Name, + humanize.Time(safeParseTime(play.CreatedAt)), + playStatus(play), + link, + } + + fmt.Fprintln(p.writer, strings.Join(fields, "\t")) +} + +func (p *listPrinter) flush() { + p.writer.Flush() +} + +func playStatus(play *api.Play) string { + if play.Running { + return fmt.Sprintf("running (expires in %s)", + humanize.Time(time.Now().Add(time.Duration(play.ExpiresIn)*time.Millisecond))) + } + if play.Destroyed { + return fmt.Sprintf("terminated %s", + humanize.Time(safeParseTime(play.LastStateAt))) + } + if play.Failed { + return fmt.Sprintf("failed %s", + humanize.Time(safeParseTime(play.LastStateAt))) + } + + return "unknown" +} + +func safeParseTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339, s) + return t +} diff --git a/cmd/playground/playground.go b/cmd/playground/playground.go new file mode 100644 index 0000000..cf2fb88 --- /dev/null +++ b/cmd/playground/playground.go @@ -0,0 +1,23 @@ +package playground + +import ( + "github.com/spf13/cobra" + + "github.com/iximiuz/labctl/internal/labcli" +) + +func NewCommand(cli labcli.CLI) *cobra.Command { + cmd := &cobra.Command{ + Use: "playground [playground-name]", + Aliases: []string{"p", "playgrounds"}, + Short: "List, start and stop playgrounds", + } + + cmd.AddCommand( + newListCommand(cli), + newStartCommand(cli), + newStopCommand(cli), + ) + + return cmd +} diff --git a/cmd/playground/start.go b/cmd/playground/start.go new file mode 100644 index 0000000..eda1980 --- /dev/null +++ b/cmd/playground/start.go @@ -0,0 +1,73 @@ +package playground + +import ( + "context" + "fmt" + + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" + + "github.com/iximiuz/labctl/internal/api" + "github.com/iximiuz/labctl/internal/labcli" +) + +type startOptions struct { + playground string + + open bool + quiet bool +} + +func newStartCommand(cli labcli.CLI) *cobra.Command { + var opts startOptions + + cmd := &cobra.Command{ + Use: "start [flags] ", + Short: `Start a new playground, possibly open it in a browser`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli.SetQuiet(opts.quiet) + + opts.playground = args[0] + + return labcli.WrapStatusError(runStartPlayground(cmd.Context(), cli, &opts)) + }, + } + + flags := cmd.Flags() + + flags.BoolVar( + &opts.open, + "open", + false, + `Open the playground page in a browser`, + ) + flags.BoolVarP( + &opts.quiet, + "quiet", + "q", + false, + `Only print playground's ID`, + ) + + return cmd +} + +func runStartPlayground(ctx context.Context, cli labcli.CLI, opts *startOptions) error { + play, err := cli.Client().CreatePlay(ctx, api.CreatePlayRequest{ + Playground: opts.playground, + }) + if err != nil { + return fmt.Errorf("couldn't create a new playground: %w", err) + } + + cli.PrintAux("Opening %s in your browser...\n", play.PageURL) + + if err := open.Run(play.PageURL); err != nil { + cli.PrintAux("Couldn't open the browser. Copy the above URL into a browser manually to access the playground.\n") + } + + cli.PrintOut("%s\n", play.ID) + + return nil +} diff --git a/cmd/playground/stop.go b/cmd/playground/stop.go new file mode 100644 index 0000000..c0af5d1 --- /dev/null +++ b/cmd/playground/stop.go @@ -0,0 +1,80 @@ +package playground + +import ( + "context" + "fmt" + "time" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + + "github.com/iximiuz/labctl/internal/labcli" +) + +const stopCommandTimeout = 5 * time.Minute + +type stopOptions struct { + playID string + + quiet bool +} + +func newStopCommand(cli labcli.CLI) *cobra.Command { + var opts stopOptions + + cmd := &cobra.Command{ + Use: "stop [flags] ", + Short: `Start a new playground, possibly open it in a browser`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli.SetQuiet(opts.quiet) + + opts.playID = args[0] + + return labcli.WrapStatusError(runStopPlayground(cmd.Context(), cli, &opts)) + }, + } + + flags := cmd.Flags() + + flags.BoolVarP( + &opts.quiet, + "quiet", + "q", + false, + `Do not print any diagnostic messages`, + ) + + return cmd +} + +func runStopPlayground(ctx context.Context, cli labcli.CLI, opts *stopOptions) error { + cli.PrintAux("Stopping playground %s...\n", opts.playID) + + if err := cli.Client().DeletePlay(ctx, opts.playID); err != nil { + return fmt.Errorf("couldn't delete the playground: %w", err) + } + + s := spinner.New(spinner.CharSets[38], 300*time.Millisecond) + s.Writer = cli.AuxStream() + s.Prefix = "Waiting for playground to terminate... " + s.Start() + + ctx, cancel := context.WithTimeout(ctx, stopCommandTimeout) + defer cancel() + + for ctx.Err() == nil { + if play, err := cli.Client().GetPlay(ctx, opts.playID); err == nil && !play.Active { + s.FinalMSG = "Waiting for playground to terminate... Done.\n" + s.Stop() + + return nil + } + + time.Sleep(2 * time.Second) + } + + cli.PrintAux("Playground has been stopped.\n") + + return nil +} diff --git a/go.mod b/go.mod index 29d167c..d632cb3 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.0 require ( github.com/briandowns/spinner v1.23.0 github.com/docker/cli v25.0.3+incompatible + github.com/dustin/go-humanize v1.0.1 github.com/google/uuid v1.6.0 github.com/iximiuz/wsmux v0.0.2 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a diff --git a/go.sum b/go.sum index 21f88d1..d80ed2f 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6/EsX/6284= github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/api/account.go b/internal/api/account.go index 685749d..b0aab34 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -9,7 +9,7 @@ type Me struct { GithubProfileId string `json:"githubProfileId" yaml:"githubProfileId"` } -func (c *Client) GetMe(ctx context.Context) (*Me, error) { +func (c *Client) GetAccount(ctx context.Context) (*Me, error) { var me Me return &me, c.GetInto(ctx, "/account", nil, nil, &me) } diff --git a/internal/api/plays.go b/internal/api/plays.go index 993c7cf..19c918d 100644 --- a/internal/api/plays.go +++ b/internal/api/plays.go @@ -5,16 +5,21 @@ import ( ) type Play struct { - ID string `json:"id" yaml:"id"` - CreatedAt string `json:"createdAt" yaml:"createdAt"` - UpdatedAt string `json:"updatedAt" yaml:"updatedAt"` - ExpiresIn int `json:"expiresIn" yaml:"expiresIn"` + ID string `json:"id" yaml:"id"` + + CreatedAt string `json:"createdAt" yaml:"createdAt"` + UpdatedAt string `json:"updatedAt" yaml:"updatedAt"` + LastStateAt string `json:"lastStateAt" yaml:"lastStateAt"` + + ExpiresIn int `json:"expiresIn" yaml:"expiresIn"` Playground Playground `json:"playground" yaml:"playground"` - PageURL string `json:"pageUrl" yaml:"pageUrl"` - Active bool `json:"active" yaml:"active"` - Running bool `json:"running" yaml:"running"` + PageURL string `json:"pageUrl" yaml:"pageUrl"` + Active bool `json:"active" yaml:"active"` + Running bool `json:"running" yaml:"running"` + Destroyed bool `json:"destroyed" yaml:"destroyed"` + Failed bool `json:"failed" yaml:"failed"` Machines []Machine `json:"machines" yaml:"machines"` } @@ -36,16 +41,40 @@ type Machine struct { NetworkPerf string `json:"networkPerf"` } +type CreatePlayRequest struct { + Playground string `json:"playground"` +} + +func (c *Client) CreatePlay(ctx context.Context, req CreatePlayRequest) (*Play, error) { + body, err := toJSONBody(req) + if err != nil { + return nil, err + } + + var p Play + return &p, c.PostInto(ctx, "/plays", nil, nil, body, &p) +} + func (c *Client) GetPlay(ctx context.Context, id string) (*Play, error) { var p Play return &p, c.GetInto(ctx, "/plays/"+id, nil, nil, &p) } -func (c *Client) ListPlays(ctx context.Context) ([]Play, error) { - var plays []Play +func (c *Client) ListPlays(ctx context.Context) ([]*Play, error) { + var plays []*Play return plays, c.GetInto(ctx, "/plays", nil, nil, &plays) } +func (c *Client) DeletePlay(ctx context.Context, id string) error { + resp, err := c.Delete(ctx, "/plays/"+id, nil, nil) + if err != nil { + return err + } + resp.Body.Close() + + return nil +} + type PortAccess string const ( diff --git a/main.go b/main.go index 4138520..0045f06 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/iximiuz/labctl/cmd/auth" + "github.com/iximiuz/labctl/cmd/playground" "github.com/iximiuz/labctl/cmd/portforward" "github.com/iximiuz/labctl/cmd/ssh" "github.com/iximiuz/labctl/cmd/sshproxy" @@ -60,6 +61,7 @@ func main() { cmd.AddCommand( auth.NewCommand(cli), + playground.NewCommand(cli), portforward.NewCommand(cli), ssh.NewCommand(cli), sshproxy.NewCommand(cli),