diff --git a/api/client.go b/api/client.go index 60a3b479f1..8cbdee7a8f 100644 --- a/api/client.go +++ b/api/client.go @@ -44,6 +44,9 @@ const ( EnvBoundaryToken = "BOUNDARY_TOKEN" EnvBoundaryRateLimit = "BOUNDARY_RATE_LIMIT" EnvBoundarySRVLookup = "BOUNDARY_SRV_LOOKUP" + + AsciiCastMimeType = "application/x-asciicast" + StreamChunkSize = 1024 * 64 // stream chuck buffer size ) // Config is used to configure the creation of the client diff --git a/api/sessionrecordings/custom.go b/api/sessionrecordings/custom.go index 2835ff75ec..3afeaa0d7f 100644 --- a/api/sessionrecordings/custom.go +++ b/api/sessionrecordings/custom.go @@ -4,14 +4,18 @@ package sessionrecordings import ( + "bytes" "context" + "errors" "fmt" "io" "net/url" -) -const chunkSize = 64 * 1024 // assume we're using 64 KiB see: https://github.com/grpc/grpc.github.io/issues/371 + "github.com/hashicorp/boundary/api" +) +// Download will of course download the request session recording resource. +// Currently it always requests a mime-type of asciicast. func (c *Client) Download(ctx context.Context, contentId string, opt ...Option) (io.ReadCloser, error) { switch { case contentId == "": @@ -22,11 +26,12 @@ func (c *Client) Download(ctx context.Context, contentId string, opt ...Option) opts, apiOpts := getOpts(opt...) - req, err := c.client.NewRequest(ctx, "GET", "session_recordings/"+url.PathEscape(contentId)+":download", nil, apiOpts...) + req, err := c.client.NewRequest(ctx, "GET", "session-recordings/"+url.PathEscape(contentId)+":download", nil, apiOpts...) if err != nil { return nil, fmt.Errorf("error creating download request: %w", err) } - req.Header.Set("Accept", "application/x-asciicast") + opts.queryMap["mime_type"] = api.AsciiCastMimeType + req.Header.Set("Accept", api.AsciiCastMimeType) if len(opts.queryMap) > 0 { q := url.Values{} @@ -40,5 +45,15 @@ func (c *Client) Download(ctx context.Context, contentId string, opt ...Option) if err != nil { return nil, fmt.Errorf("error performing client request during download call: %w", err) } + if resp.StatusCode() >= 400 { + resp.Body = new(bytes.Buffer) + if _, err := resp.Body.ReadFrom(resp.HttpResponse().Body); err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + if resp.Body.Len() > 0 { + return nil, errors.New(resp.Body.String()) + } + return nil, fmt.Errorf("error reading response body: status was %d", resp.StatusCode()) + } return resp.HttpResponse().Body, nil } diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index 6cf3a91a17..2fb48636e6 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -118,6 +118,8 @@ type Command struct { FlagRecursive bool FlagFilter string FlagTags map[string][]string + FlagOutputFile string // the output file for the command + FlagNoClobber bool // Don't clobber the output file // Attribute values FlagAttributes string diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 5724ce0dde..3f9528454a 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -1042,6 +1042,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "list", }, nil }, + "session-recordings download": func() (cli.Command, error) { + return &sessionrecordingscmd.DownloadCommand{ + Command: base.NewCommand(ui), + }, nil + }, "storage-buckets": func() (cli.Command, error) { return &storagebucketscmd.Command{ diff --git a/internal/cmd/commands/sessionrecordingscmd/download.go b/internal/cmd/commands/sessionrecordingscmd/download.go new file mode 100644 index 0000000000..bca6f8ca0b --- /dev/null +++ b/internal/cmd/commands/sessionrecordingscmd/download.go @@ -0,0 +1,177 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package sessionrecordingscmd + +import ( + "errors" + "fmt" + "io" + "os" + "strconv" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/sessionrecordings" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/mitchellh/cli" + "github.com/mitchellh/go-wordwrap" + "github.com/posener/complete" +) + +var ( + _ cli.Command = (*DownloadCommand)(nil) + _ cli.CommandAutocomplete = (*DownloadCommand)(nil) +) + +const ( + castExt = ".cast" // default download file extension (is overridden when an output file is specified) +) + +type DownloadCommand struct { + *base.Command +} + +func (c *DownloadCommand) Synopsis() string { + return wordwrap.WrapString("Download a session recording", base.TermWidth) +} + +func (c *DownloadCommand) Help() string { + return base.WrapForHelpText([]string{ + "Usage: boundary session-recordings download [args]", + "", + " Download a session recording resource. Example:", + "", + ` $ boundary session-recordings download -id chr_u6e9wJ8B8H`, + "", + "", + }) + c.Flags().Help() +} + +func (c *DownloadCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient | base.FlagSetOutputFormat) + f := set.NewFlagSet("Command Options") + + f.StringVar(&base.StringVar{ + Name: "id", + Target: &c.FlagId, + Usage: "The id of the session recording resource to download", + }) + f.StringVar(&base.StringVar{ + Name: "output", + Target: &c.FlagOutputFile, + Usage: "An optional output file for the download. If not provided the recording id will be used with a \".cast\" extension. Use \"-\" for stdout", + Aliases: []string{"o"}, + }) + f.BoolVar(&base.BoolVar{ + Name: "no-clobber", + Target: &c.FlagNoClobber, + Usage: "An option to stop downloads that would overwrite existing files", + Aliases: []string{"nc"}, + }) + return set +} + +func (c *DownloadCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictAnything +} + +func (c *DownloadCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *DownloadCommand) Run(args []string) int { + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + switch { + case c.FlagId == "": + c.PrintCliError(errors.New("ID must be provided via -id")) + return base.CommandUserError + } + + client, err := c.Client() + if c.WrapperCleanupFunc != nil { + defer func() { + if err := c.WrapperCleanupFunc(); err != nil { + c.PrintCliError(fmt.Errorf("Error cleaning kms wrapper: %w", err)) + } + }() + } + if err != nil { + c.PrintCliError(fmt.Errorf("Error creating API client: %w", err)) + return base.CommandCliError + } + + sClient := sessionrecordings.NewClient(client) + result, err := sClient.Download(c.Context, c.FlagId) + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil { + c.PrintApiError(apiErr, "Error from controller when downloading session recording") + return base.CommandApiError + } + c.PrintCliError(fmt.Errorf("Download error: %w", err)) + return base.CommandCliError + } + + var outFile *os.File + switch { + case c.FlagOutputFile == "-": + outFile = os.Stdout + case c.FlagOutputFile != "" && c.FlagNoClobber: + _, err := os.Stat(c.FlagOutputFile) + switch { + case os.IsNotExist(err): + outFile, err = os.Create(c.FlagOutputFile) + if err != nil { + c.PrintCliError(fmt.Errorf("Unable to create download file %q when \"-nc\" was provided: %w", c.FlagOutputFile, err)) + return base.CommandCliError + } + defer outFile.Close() + case err != nil: + c.PrintCliError(fmt.Errorf("Error trying to check if file %q exists when \"-nc\" was provided: %w", c.FlagOutputFile, err)) + return base.CommandCliError + default: + c.PrintCliError(fmt.Errorf("Error trying to overwrite to an existing file %q when \"-nc\" was provided", c.FlagOutputFile)) + return base.CommandCliError + } + case c.FlagOutputFile != "": + outFile, err = os.Create(c.FlagOutputFile) + if err != nil { + c.PrintCliError(fmt.Errorf("Unable to create requested download file %q: %w", c.FlagOutputFile, err)) + return base.CommandCliError + } + defer outFile.Close() + default: + fileName := getNextFileName(c.FlagId) + outFile, err = os.Create(fileName) + if err != nil { + c.PrintCliError(fmt.Errorf("Unable to create download file %q: %w", fileName, err)) + return base.CommandCliError + } + defer outFile.Close() + } + + if _, err := io.Copy(outFile, result); err != nil { + c.PrintCliError(fmt.Errorf("Error reading download stream: %w", err)) + return base.CommandCliError + } + return base.CommandSuccess +} + +func getNextFileName(baseName string) string { + if _, err := os.Stat(baseName + castExt); os.IsNotExist(err) { + return baseName + castExt + } + startIndex := 1 + for { + fileName := baseName + castExt + "." + strconv.Itoa(startIndex) + if _, err := os.Stat(fileName); os.IsNotExist(err) { + return fileName + } + startIndex++ + } +} diff --git a/internal/cmd/commands/sessionrecordingscmd/funcs.go b/internal/cmd/commands/sessionrecordingscmd/funcs.go index 17ca7e7611..d8f9e6cfc9 100644 --- a/internal/cmd/commands/sessionrecordingscmd/funcs.go +++ b/internal/cmd/commands/sessionrecordingscmd/funcs.go @@ -28,6 +28,15 @@ func (c *Command) extraHelpFunc(helpMap map[string]func() string) string { "", ` $ boundary session-recordings read -id s_1234567890`, "", + " List session recording:", + "", + ` $ boundary session-recordings list -scope-id global`, + "", + " Download a session recording:", + "", + ` $ boundary session-recordings download -id chr_1234567890`, + "", + " Please see the sessions subcommand help for detailed usage information.", }) diff --git a/internal/daemon/controller/gateway.go b/internal/daemon/controller/gateway.go index bbd35581d2..3648c2e185 100644 --- a/internal/daemon/controller/gateway.go +++ b/internal/daemon/controller/gateway.go @@ -81,7 +81,7 @@ func newGrpcServer( if err != nil { return nil, "", errors.Wrap(ctx, err, op, errors.WithMsg("unable to generate gateway ticket")) } - requestCtxInterceptor, err := requestCtxUnaryInterceptor(ctx, iamRepoFn, authTokenRepoFn, serversRepoFn, passwordAuthRepoFn, oidcAuthRepoFn, ldapAuthRepoFn, kms, ticket, eventer) + unaryCtxInterceptor, err := requestCtxUnaryInterceptor(ctx, iamRepoFn, authTokenRepoFn, serversRepoFn, passwordAuthRepoFn, oidcAuthRepoFn, ldapAuthRepoFn, kms, ticket, eventer) if err != nil { return nil, "", err } @@ -111,7 +111,7 @@ func newGrpcServer( ), grpc.UnaryInterceptor( grpc_middleware.ChainUnaryServer( - requestCtxInterceptor, // populated requestInfo from headers into the request ctx + unaryCtxInterceptor, // populated requestInfo from headers into the request ctx errorInterceptor(ctx), // convert domain and api errors into headers for the http proxy subtypes.AttributeTransformerInterceptor(ctx), // convert to/from generic attributes from/to subtype specific attributes auditRequestInterceptor(ctx), // before we get started, audit the request diff --git a/internal/daemon/controller/interceptor_test.go b/internal/daemon/controller/interceptor_test.go index d743cb26cc..8329a9c791 100644 --- a/internal/daemon/controller/interceptor_test.go +++ b/internal/daemon/controller/interceptor_test.go @@ -36,7 +36,7 @@ import ( "google.golang.org/protobuf/proto" ) -func Test_requestCtxInterceptor(t *testing.T) { +func Test_unaryCtxInterceptor(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) @@ -91,13 +91,13 @@ func Test_requestCtxInterceptor(t *testing.T) { return ctx, nil } - c := event.TestEventerConfig(t, "Test_requestCtxInterceptor", event.TestWithAuditSink(t), event.TestWithObservationSink(t)) + c := event.TestEventerConfig(t, "Test_unaryCtxInterceptor", event.TestWithAuditSink(t), event.TestWithObservationSink(t)) testLock := &sync.Mutex{} testLogger := hclog.New(&hclog.LoggerOptions{ Mutex: testLock, Name: "test", }) - testEventer, err := event.NewEventer(testLogger, testLock, "Test_requestCtxInterceptor", c.EventerConfig) + testEventer, err := event.NewEventer(testLogger, testLock, "Test_unaryCtxInterceptor", c.EventerConfig) require.NoError(t, err) tests := []struct { @@ -404,13 +404,13 @@ func Test_streamCtxInterceptor(t *testing.T) { factoryCtx := context.Background() - c := event.TestEventerConfig(t, "Test_requestCtxInterceptor", event.TestWithAuditSink(t), event.TestWithObservationSink(t)) + c := event.TestEventerConfig(t, "Test_streamCtxInterceptor", event.TestWithAuditSink(t), event.TestWithObservationSink(t)) testLock := &sync.Mutex{} testLogger := hclog.New(&hclog.LoggerOptions{ Mutex: testLock, Name: "test", }) - testEventer, err := event.NewEventer(testLogger, testLock, "Test_requestCtxInterceptor", c.EventerConfig) + testEventer, err := event.NewEventer(testLogger, testLock, "Test_streamCtxInterceptor", c.EventerConfig) require.NoError(t, err) tests := []struct { name string @@ -780,13 +780,13 @@ func Test_workerRequestInfoInterceptor(t *testing.T) { return ctx, nil } - c := event.TestEventerConfig(t, "Test_requestCtxInterceptor", event.TestWithAuditSink(t), event.TestWithObservationSink(t)) + c := event.TestEventerConfig(t, "Test_unaryCtxInterceptor", event.TestWithAuditSink(t), event.TestWithObservationSink(t)) testLock := &sync.Mutex{} testLogger := hclog.New(&hclog.LoggerOptions{ Mutex: testLock, Name: "test", }) - testEventer, err := event.NewEventer(testLogger, testLock, "Test_requestCtxInterceptor", c.EventerConfig) + testEventer, err := event.NewEventer(testLogger, testLock, "Test_unaryCtxInterceptor", c.EventerConfig) require.NoError(t, err) tests := []struct {