Skip to content

Commit

Permalink
feat(server): add --wait flag to "shutdown" command (#569)
Browse files Browse the repository at this point in the history
As described in #489
  • Loading branch information
phm07 committed Oct 19, 2023
1 parent feed635 commit 3ce048c
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 35 deletions.
4 changes: 2 additions & 2 deletions internal/cmd/context/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
61 changes: 59 additions & 2 deletions internal/cmd/server/shutdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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
},
}
88 changes: 88 additions & 0 deletions internal/cmd/server/shutdown_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
70 changes: 39 additions & 31 deletions internal/state/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}

Expand All @@ -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)
Expand All @@ -89,20 +89,14 @@ 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`)")
}
return nil
}

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 {
Expand All @@ -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
}

0 comments on commit 3ce048c

Please sign in to comment.