Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/reference/manual/hcloud_server.md

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

44 changes: 44 additions & 0 deletions docs/reference/manual/hcloud_server_screenshot.md

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

3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/hetznercloud/cli

go 1.26.0
go 1.26.5

toolchain go1.27.2

Expand All @@ -20,6 +20,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/swaggest/assertjson v1.10.0
github.com/tenthirtyam/go-vnc v1.0.1
go.uber.org/mock v0.6.0
golang.org/x/crypto v0.57.0
golang.org/x/exp v0.0.0-20260718201538-764159d718ef
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggest/assertjson v1.10.0 h1:OYIx29UbgqjwAekKbeIO8T0XbAMyViJ8qqrVj2GQY2M=
github.com/swaggest/assertjson v1.10.0/go.mod h1:31+ufMpzrCO/daIRZ4s1UZAA1R2q9EEUfJaoczgAty8=
github.com/tenthirtyam/go-vnc v1.0.1 h1:jJBNsPkhnkNR1va5yfFY7v7rVc1C45x4pBMCr0a54oU=
github.com/tenthirtyam/go-vnc v1.0.1/go.mod h1:J009Y0mesUZAmX6vBWBjcI8f7mOBNtQmhs47mman5NU=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"io"
"log/slog"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -104,6 +105,14 @@ func NewRootCommand(s state.State) *cobra.Command {
out = io.Discard
}
cmd.SetOut(out)

isDebug, err := config.OptionDebug.Get(s.Config())
if err != nil {
return err
}
if isDebug {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
return nil
}
return cmd
Expand Down
12 changes: 7 additions & 5 deletions internal/cmd/base/experimental.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ func ExperimentalWrapper(product, maturity, url string) func(state.State, *cobra
}

cmd.Short = "[experimental] " + cmd.Short
cmd.Long += fmt.Sprintf(`

Experimental: %s is %s, breaking changes may occur within minor releases.
See %s for more details.
`, product, maturity, url)
cmd.Long += fmt.Sprintf(
"\n\nExperimental: %s is %s, breaking changes may occur within minor releases.\n",
product, maturity,
)
if url != "" {
cmd.Long += fmt.Sprintf("See %s for more details.\n", url)
}

cmd.PreRunE = util.ChainRunE(cmd.PreRunE, func(cmd *cobra.Command, _ []string) error {
hideWarning, err := config.OptionNoExperimentalWarning.Get(s.Config())
Expand Down
190 changes: 190 additions & 0 deletions internal/cmd/server/screenshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package server

import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/signal"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/hetznercloud/cli/internal/cmd/base"
"github.com/hetznercloud/cli/internal/cmd/cmpl"
"github.com/hetznercloud/cli/internal/cmd/util"
"github.com/hetznercloud/cli/internal/hcapi2"
"github.com/hetznercloud/cli/internal/state"
"github.com/hetznercloud/cli/internal/state/config"
)

var ScreenshotCmd = base.Cmd{
BaseCobraCommand: func(client hcapi2.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "screenshot [options] <server>",
Short: "Take a screenshot of the Server VNC console",
ValidArgsFunction: cmpl.SuggestArgs(cmpl.SuggestCandidatesF(client.Server().Names)),
TraverseChildren: true,
DisableFlagsInUseLine: true,
}
cmd.Flags().StringP("output", "o", "screenshot.png", "Output file for the screenshot ('-' for stdout; must be a .png file otherwise)")
cmd.Flags().Duration("timeout", 10*time.Second, "Timeout for taking the screenshot (0s disables the timeout)")
cmd.Flags().Bool("overwrite", false, "Overwrite the output file if it exists already (true, false)")
cmd.Flags().Duration("watch", 0, "Take a screenshot at the given interval (minimum 1s)")

cmd.MarkFlagsMutuallyExclusive("timeout", "watch")
return cmd
},
Experimental: base.ExperimentalWrapper("The server screenshot command", "experimental", ""),
Run: func(s state.State, cmd *cobra.Command, args []string) error {
idOrName := args[0]
output, _ := cmd.Flags().GetString("output")
timeout, _ := cmd.Flags().GetDuration("timeout")
overwrite, _ := cmd.Flags().GetBool("overwrite")
watch, _ := cmd.Flags().GetDuration("watch")
isWatch := cmd.Flags().Changed("watch")
toStdout := output == "-"

if isWatch {
if watch < time.Second {
return fmt.Errorf("--watch interval must be at least 1s")
}
if toStdout {
return fmt.Errorf("--watch cannot be used with output to stdout")
}
}

if toStdout && s.Terminal().StdoutIsTerminal() {
return fmt.Errorf("interactive tty detected. use --output <file> or redirect stdout")
}

if !toStdout && !overwrite {
firstOutput := output
if isWatch {
firstOutput = util.IndexedFilePath(output, 1)
}
if _, err := os.Stat(firstOutput); err == nil {
return fmt.Errorf("output file %s already exists", firstOutput)
}
}

server, _, err := s.Client().Server().Get(s, idOrName)
if err != nil {
return err
}
if server == nil {
return fmt.Errorf("Server not found: %s", idOrName)
}

result, _, err := s.Client().Server().RequestConsole(s, server)
if err != nil {
return err
}

if err := s.WaitForActions(s, cmd, result.Action); err != nil {
return err
}

if !toStdout && !strings.HasSuffix(strings.ToLower(output), ".png") {
cmd.Printf("Warning: Only .png output file are supported\n")
}

isDebug, err := config.OptionDebug.Get(s.Config())
if err != nil {
return err
}
var debugOut io.Writer
if isDebug {
debugOut = cmd.ErrOrStderr()
}

if isWatch {
return watchScreenshots(s, cmd, result.WSSURL, output, overwrite, watch, debugOut)
}

ctx := context.Context(s)
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}

session, err := util.OpenScreenshotSession(ctx, result.WSSURL, debugOut)
if err != nil {
return err
}
defer session.Close()

var buf bytes.Buffer
if err := session.Capture(ctx, &buf); err != nil {
return err
}

if toStdout {
_, err := cmd.OutOrStdout().Write(buf.Bytes())
return err
}
if err := writeScreenshotFile(output, buf.Bytes(), overwrite); err != nil {
return err
}
cmd.Printf("Screenshot captured and saved to '%s'\n", output)
return nil
},
}

func watchScreenshots(
ctx context.Context, cmd *cobra.Command, wsURL, output string, overwrite bool,
interval time.Duration, debugOut io.Writer,
) error {
// capture ctrl + c to stop taking screenshots
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()

session, err := util.OpenScreenshotSession(ctx, wsURL, debugOut)
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
defer session.Close()

cmd.Printf("Taking a screenshot every %s, press Ctrl+C to stop\n", interval)

ticker := time.NewTicker(interval)
defer ticker.Stop()

for idx := 1; ; idx++ {
var buf bytes.Buffer
if err := session.Capture(ctx, &buf); err != nil {
if ctx.Err() != nil {
// ctrl + c signal received
return nil
}
return err
}

path := util.IndexedFilePath(output, idx)
if err := writeScreenshotFile(path, buf.Bytes(), overwrite); err != nil {
return err
}
cmd.Printf("Screenshot captured and saved to '%s'\n", path)

select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}

func writeScreenshotFile(path string, data []byte, overwrite bool) error {
if !overwrite {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("output file %s already exists", path)
}
}
return os.WriteFile(path, data, 0600)
}
1 change: 1 addition & 0 deletions internal/cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func NewCommand(s state.State) *cobra.Command {
SSHCmd.CobraCommand(s),
IPCmd.CobraCommand(s),
RequestConsoleCmd.CobraCommand(s),
ScreenshotCmd.CobraCommand(s),
ResetPasswordCmd.CobraCommand(s),
MetricsCmd.CobraCommand(s),
SetRDNSCmd.CobraCommand(s),
Expand Down
Loading
Loading