diff --git a/cli/requestlogging.go b/cli/requestlogging.go new file mode 100644 index 0000000000000..863b782ce36ce --- /dev/null +++ b/cli/requestlogging.go @@ -0,0 +1,112 @@ +package cli + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +type loggingRoundTripper struct { + http.RoundTripper + io.Writer + + logRequestBodies bool + logResponseBodies bool +} + +func (lrt loggingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + inner := lrt.RoundTripper + if inner == nil { + inner = http.DefaultTransport + } + + var requestBody bytes.Buffer + if lrt.logRequestBodies { + teeReader := io.TeeReader(request.Body, &requestBody) + wrappedBody := &readCloser{teeReader, request.Body} + request = request.Clone(request.Context()) + request.Body = wrappedBody + } + + response, err := inner.RoundTrip(request) + + var displayedStatusCode string + if err != nil { + displayedStatusCode = "(err)" + } else { + displayedStatusCode = strconv.Itoa(response.StatusCode) + } + _, _ = fmt.Fprintf(lrt.Writer, "sending request: %s %s status: %s\n", request.Method, request.URL.String(), displayedStatusCode) + + if lrt.logRequestBodies { + printRequestBodyLog(lrt.Writer, request, &requestBody) + } + + if lrt.logResponseBodies { + response = wrapResponse(lrt.Writer, response) + } + + return response, err +} + +type readCloser struct { + io.Reader + io.Closer +} + +func formatBody(contentType string, body *bytes.Buffer) string { + bareType, _, _ := strings.Cut(contentType, ";") + if bareType == "application/json" || strings.HasPrefix(bareType, "text/") { + return fmt.Sprintf("%s: %s", contentType, body.Bytes()) + } + return fmt.Sprintf("%s, %d bytes", contentType, body.Len()) +} + +func printRequestBodyLog(writer io.Writer, request *http.Request, body *bytes.Buffer) { + // omit bodies that are empty and expected to be empty + if body.Len() == 0 && emptyRequestExpected(request.Method) { + return + } + + message := formatBody(request.Header.Get("Content-Type"), body) + _, _ = fmt.Fprintf(writer, "\trequest body: %s\n", message) +} + +func emptyRequestExpected(method string) bool { + return !(method == "POST" || method == "PUT" || method == "PATCH") +} + +type responseBodyLogger struct { + writer io.Writer + + body io.ReadCloser + bodyContent bytes.Buffer + contentType string +} + +func wrapResponse(writer io.Writer, response *http.Response) *http.Response { + newResponse := new(http.Response) + *newResponse = *response + + logger := responseBodyLogger{ + writer: writer, + contentType: response.Header.Get("Content-Type"), + } + logger.body = &readCloser{io.TeeReader(response.Body, &logger.bodyContent), response.Body} + newResponse.Body = &logger + + return newResponse +} + +func (logger *responseBodyLogger) Read(p []byte) (int, error) { + return logger.body.Read(p) +} + +func (logger *responseBodyLogger) Close() error { + message := formatBody(logger.contentType, &logger.bodyContent) + _, _ = fmt.Fprintf(logger.writer, "\tresponse body: %s\n", message) + return logger.body.Close() +} diff --git a/cli/requestlogging_test.go b/cli/requestlogging_test.go new file mode 100644 index 0000000000000..cf550e6a3c286 --- /dev/null +++ b/cli/requestlogging_test.go @@ -0,0 +1,73 @@ +package cli_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +func TestRequestLogging(t *testing.T) { + t.Parallel() + t.Run("List", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + _ = coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "ls", "--verbose") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + pty.ExpectMatch("GET " + client.URL.String() + "/api/v2/workspaces status: 200") + <-doneChan + }) + + t.Run("NoAuthentication", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + cmd, root := clitest.New(t, "ls", "--verbose") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.Error(t, err) + }() + pty.ExpectMatch("GET " + client.URL.String() + "/api/v2/workspaces status: 401") + <-doneChan + }) + + t.Run("InvalidUrl", func(t *testing.T) { + t.Parallel() + parsedURL, err := url.Parse("invalidprotocol://foobar") + require.NoError(t, err) + client := codersdk.New(parsedURL) + cmd, root := clitest.New(t, "ls", "--verbose") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.Error(t, err) + }() + pty.ExpectMatch("GET " + client.URL.String() + "/api/v2/workspaces status: (err)") + <-doneChan + }) +} diff --git a/cli/root.go b/cli/root.go index a0e0a389561ed..0da35bee9dcde 100644 --- a/cli/root.go +++ b/cli/root.go @@ -35,6 +35,7 @@ const ( varGlobalConfig = "global-config" varNoOpen = "no-open" varForceTty = "force-tty" + varVerbose = "verbose" ) func init() { @@ -91,11 +92,14 @@ func Root() *cobra.Command { cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.") cmd.PersistentFlags().String(varToken, "", "Specify an authentication token.") + cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.") + cmd.PersistentFlags().CountP("verbose", "v", "Print more verbose output. Can be specified multiple times.") + + // Hidden flags for internal use. cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.") _ = cmd.PersistentFlags().MarkHidden(varAgentToken) cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "Specify the URL for an agent to access your deployment.") _ = cmd.PersistentFlags().MarkHidden(varAgentURL) - cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.") cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY.") _ = cmd.PersistentFlags().MarkHidden(varForceTty) cmd.PersistentFlags().Bool(varNoOpen, false, "Block automatically opening URLs in the browser.") @@ -126,7 +130,22 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) { return nil, err } } - client := codersdk.New(serverURL) + + verbosityLevel, err := cmd.Flags().GetCount(varVerbose) + if err != nil { + return nil, err + } + + var client *codersdk.Client + if verbosityLevel >= 1 { + client = codersdk.NewWithRoundTripper(serverURL, &loggingRoundTripper{ + Writer: cmd.OutOrStderr(), + logRequestBodies: verbosityLevel >= 2, + logResponseBodies: verbosityLevel >= 3, + }) + } else { + client = codersdk.New(serverURL) + } client.SessionToken = token return client, nil } diff --git a/codersdk/client.go b/codersdk/client.go index 0367c912a5bbe..70d7ac255d4da 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -26,6 +26,16 @@ func New(serverURL *url.URL) *Client { } } +// NewWithRoundTripper behaves like New, but allows specifying a custom implementation of http.RoundTripper. +func NewWithRoundTripper(serverURL *url.URL, roundTripper http.RoundTripper) *Client { + return &Client{ + URL: serverURL, + HTTPClient: &http.Client{ + Transport: roundTripper, + }, + } +} + // Client is an HTTP caller for methods to the Coder API. // @typescript-ignore Client type Client struct {