diff --git a/internal/cmd/context/create.go b/internal/cmd/context/create.go index fe6a0de4..6fc0f03a 100644 --- a/internal/cmd/context/create.go +++ b/internal/cmd/context/create.go @@ -24,8 +24,8 @@ func newCreateCommand(cli *state.State) *cobra.Command { return cmd } -func runCreate(cli *state.State, cmd *cobra.Command, args []string) error { - if !cli.Terminal() { +func runCreate(cli *state.State, _ *cobra.Command, args []string) error { + if !state.StdoutIsTerminal() { return errors.New("context create is an interactive command") } diff --git a/internal/cmd/server/shutdown.go b/internal/cmd/server/shutdown.go index 73799bfa..13e0df4b 100644 --- a/internal/cmd/server/shutdown.go +++ b/internal/cmd/server/shutdown.go @@ -2,7 +2,10 @@ package server import ( "context" + "errors" "fmt" + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "time" "github.com/hetznercloud/cli/internal/cmd/base" "github.com/hetznercloud/cli/internal/cmd/cmpl" @@ -13,16 +16,32 @@ import ( var ShutdownCommand = base.Cmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { - return &cobra.Command{ + + const description = "Shuts down a Server gracefully by sending an ACPI shutdown request. " + + "The Server operating system must support ACPI and react to the request, " + + "otherwise the Server will not shut down. Use the --wait flag to wait for the " + + "server to shut down before returning." + + cmd := &cobra.Command{ Use: "shutdown [FLAGS] SERVER", Short: "Shutdown a server", + Long: description, Args: cobra.ExactArgs(1), ValidArgsFunction: cmpl.SuggestArgs(cmpl.SuggestCandidatesF(client.Server().Names)), TraverseChildren: true, DisableFlagsInUseLine: true, } + + cmd.Flags().Bool("wait", false, "Wait for the server to shut down before exiting") + cmd.Flags().Duration("wait-timeout", 30*time.Second, "Timeout for waiting for off state after shutdown") + + return cmd }, Run: func(ctx context.Context, client hcapi2.Client, waiter state.ActionWaiter, cmd *cobra.Command, args []string) error { + + wait, _ := cmd.Flags().GetBool("wait") + timeout, _ := cmd.Flags().GetDuration("wait-timeout") + idOrName := args[0] server, _, err := client.Server().Get(ctx, idOrName) if err != nil { @@ -41,7 +60,45 @@ var ShutdownCommand = base.Cmd{ return err } - fmt.Printf("Server %d shut down\n", server.ID) + fmt.Printf("Sent shutdown signal to server %d\n", server.ID) + + if wait { + start := time.Now() + errCh := make(chan error) + + interval, _ := cmd.Flags().GetDuration("poll-interval") + if interval < time.Second { + interval = time.Second + } + + go func() { + defer close(errCh) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for server.Status != hcloud.ServerStatusOff { + if now := <-ticker.C; now.Sub(start) >= timeout { + errCh <- errors.New("failed to shut down server") + return + } + server, _, err = client.Server().GetByID(ctx, server.ID) + if err != nil { + errCh <- err + return + } + } + + errCh <- nil + }() + + if err := state.DisplayProgressCircle(errCh, "Waiting for server to shut down"); err != nil { + return err + } + + fmt.Printf("Server %d shut down\n", server.ID) + } + return nil }, } diff --git a/internal/cmd/server/shutdown_test.go b/internal/cmd/server/shutdown_test.go new file mode 100644 index 00000000..9997275f --- /dev/null +++ b/internal/cmd/server/shutdown_test.go @@ -0,0 +1,88 @@ +package server + +import ( + "context" + "github.com/golang/mock/gomock" + "github.com/hetznercloud/cli/internal/testutil" + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestShutdown(t *testing.T) { + + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := ShutdownCommand.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + var ( + server = hcloud.Server{ + ID: 42, + Name: "my server", + Status: hcloud.ServerStatusRunning, + } + ) + + fx.Client.ServerClient.EXPECT(). + Get(gomock.Any(), server.Name). + Return(&server, nil, nil) + + fx.Client.ServerClient.EXPECT(). + Shutdown(gomock.Any(), &server) + fx.ActionWaiter.EXPECT().ActionProgress(gomock.Any(), nil) + + out, err := fx.Run(cmd, []string{server.Name}) + + expOut := "Sent shutdown signal to server 42\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) +} + +func TestShutdownWait(t *testing.T) { + + fx := testutil.NewFixture(t) + defer fx.Finish() + + cmd := ShutdownCommand.CobraCommand( + context.Background(), + fx.Client, + fx.TokenEnsurer, + fx.ActionWaiter) + fx.ExpectEnsureToken() + + var ( + server = hcloud.Server{ + ID: 42, + Name: "my server", + Status: hcloud.ServerStatusRunning, + } + ) + + fx.Client.ServerClient.EXPECT(). + Get(gomock.Any(), server.Name). + Return(&server, nil, nil) + + fx.Client.ServerClient.EXPECT(). + Shutdown(gomock.Any(), &server) + fx.ActionWaiter.EXPECT().ActionProgress(gomock.Any(), nil) + + fx.Client.ServerClient.EXPECT(). + GetByID(gomock.Any(), server.ID). + Return(&server, nil, nil). + Return(&server, nil, nil). + Return(&hcloud.Server{ID: server.ID, Name: server.Name, Status: hcloud.ServerStatusOff}, nil, nil) + + out, err := fx.Run(cmd, []string{server.Name, "--wait"}) + + expOut := "Sent shutdown signal to server 42\nWaiting for server to shut down ... done\nServer 42 shut down\n" + + assert.NoError(t, err) + assert.Equal(t, expOut, out) +} diff --git a/internal/state/helpers.go b/internal/state/helpers.go index 82f53c37..5a0c40c4 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -54,8 +54,8 @@ func (c *State) Client() *hcloud.Client { return c.client } -// Terminal returns whether the CLI is run in a terminal. -func (c *State) Terminal() bool { +// StdoutIsTerminal returns whether the CLI is run in a terminal. +func StdoutIsTerminal() bool { return terminal.IsTerminal(int(os.Stdout.Fd())) } @@ -66,7 +66,7 @@ func (c *State) ActionProgress(ctx context.Context, action *hcloud.Action) error func (c *State) ActionsProgresses(ctx context.Context, actions []*hcloud.Action) error { progressCh, errCh := c.Client().Action.WatchOverallProgress(ctx, actions) - if c.Terminal() { + if StdoutIsTerminal() { progress := pb.New(100) progress.SetMaxWidth(50) // width of progress bar is too large by default progress.SetTemplateString(progressBarTpl) @@ -89,7 +89,7 @@ func (c *State) ActionsProgresses(ctx context.Context, actions []*hcloud.Action) } } -func (c *State) EnsureToken(cmd *cobra.Command, args []string) error { +func (c *State) EnsureToken(_ *cobra.Command, _ []string) error { if c.Token == "" { return errors.New("no active context or token (see `hcloud context --help`)") } @@ -97,12 +97,6 @@ func (c *State) EnsureToken(cmd *cobra.Command, args []string) error { } func (c *State) WaitForActions(ctx context.Context, actions []*hcloud.Action) error { - const ( - done = "done" - failed = "failed" - ellipsis = " ... " - ) - for _, action := range actions { resources := make(map[string]int64) for _, resource := range action.Resources { @@ -119,30 +113,44 @@ func (c *State) WaitForActions(ctx context.Context, actions []*hcloud.Action) er waitingFor = fmt.Sprintf("Waiting for volume %d to have been attached to server %d", resources["volume"], resources["server"]) } - if c.Terminal() { - fmt.Println(waitingFor) - progress := pb.New(1) // total progress of 1 will do since we use a circle here - progress.SetTemplateString(progressCircleTpl) - progress.Start() - defer progress.Finish() - - _, errCh := c.Client().Action.WatchProgress(ctx, action) - if err := <-errCh; err != nil { - progress.SetTemplateString(ellipsis + failed) - return err - } - progress.SetTemplateString(ellipsis + done) - } else { - fmt.Print(waitingFor + ellipsis) + _, errCh := c.Client().Action.WatchProgress(ctx, action) - _, errCh := c.Client().Action.WatchProgress(ctx, action) - if err := <-errCh; err != nil { - fmt.Println(failed) - return err - } - fmt.Println(done) + err := DisplayProgressCircle(errCh, waitingFor) + if err != nil { + return err } } return nil } + +func DisplayProgressCircle(errCh <-chan error, waitingFor string) error { + const ( + done = "done" + failed = "failed" + ellipsis = " ... " + ) + + if StdoutIsTerminal() { + fmt.Println(waitingFor) + progress := pb.New(1) // total progress of 1 will do since we use a circle here + progress.SetTemplateString(progressCircleTpl) + progress.Start() + defer progress.Finish() + + if err := <-errCh; err != nil { + progress.SetTemplateString(ellipsis + failed) + return err + } + progress.SetTemplateString(ellipsis + done) + } else { + fmt.Print(waitingFor + ellipsis) + + if err := <-errCh; err != nil { + fmt.Println(failed) + return err + } + fmt.Println(done) + } + return nil +}