diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81675530aa57d..fb343ce01a2b4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -427,6 +427,34 @@ const docTemplate = `{ } } }, + "/debug/ws": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Debug" + ], + "summary": "Debug Info Websocket Test", + "operationId": "debug-info-websocket-test", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/deployment/config": { "get": { "security": [ @@ -10425,6 +10453,29 @@ const docTemplate = `{ "time": { "description": "Time is the time the report was generated at.", "type": "string" + }, + "websocket": { + "$ref": "#/definitions/healthcheck.WebsocketReport" + } + } + }, + "healthcheck.WebsocketReport": { + "type": "object", + "properties": { + "error": {}, + "response": { + "$ref": "#/definitions/healthcheck.WebsocketResponse" + } + } + }, + "healthcheck.WebsocketResponse": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "code": { + "type": "integer" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2c9080fe49b74..e2ae6b5a19d89 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -363,6 +363,30 @@ } } }, + "/debug/ws": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Debug"], + "summary": "Debug Info Websocket Test", + "operationId": "debug-info-websocket-test", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/deployment/config": { "get": { "security": [ @@ -9412,6 +9436,29 @@ "time": { "description": "Time is the time the report was generated at.", "type": "string" + }, + "websocket": { + "$ref": "#/definitions/healthcheck.WebsocketReport" + } + } + }, + "healthcheck.WebsocketReport": { + "type": "object", + "properties": { + "error": {}, + "response": { + "$ref": "#/definitions/healthcheck.WebsocketResponse" + } + } + }, + "healthcheck.WebsocketResponse": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "code": { + "type": "integer" } } }, diff --git a/coderd/coderd.go b/coderd/coderd.go index 712e52460fbfe..d126f6bf63a68 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -129,7 +129,7 @@ type Options struct { // AppSecurityKey is the crypto key used to sign and encrypt tokens related to // workspace applications. It consists of both a signing and encryption key. AppSecurityKey workspaceapps.SecurityKey - HealthcheckFunc func(ctx context.Context) (*healthcheck.Report, error) + HealthcheckFunc func(ctx context.Context, apiKey string) (*healthcheck.Report, error) HealthcheckTimeout time.Duration HealthcheckRefresh time.Duration @@ -256,10 +256,11 @@ func New(options *Options) *API { options.TemplateScheduleStore.Store(&v) } if options.HealthcheckFunc == nil { - options.HealthcheckFunc = func(ctx context.Context) (*healthcheck.Report, error) { + options.HealthcheckFunc = func(ctx context.Context, apiKey string) (*healthcheck.Report, error) { return healthcheck.Run(ctx, &healthcheck.ReportOptions{ AccessURL: options.AccessURL, DERPMap: options.DERPMap.Clone(), + APIKey: apiKey, }) } } @@ -787,6 +788,7 @@ func New(options *Options) *API { r.Get("/coordinator", api.debugCoordinator) r.Get("/health", api.debugDeploymentHealth) + r.Get("/ws", (&healthcheck.WebsocketEchoServer{}).ServeHTTP) }) }) @@ -874,6 +876,7 @@ type API struct { Experiments codersdk.Experiments healthCheckGroup *singleflight.Group[string, *healthcheck.Report] + healthCheckCache atomic.Pointer[healthcheck.Report] } // Close waits for all WebSocket connections to drain before returning. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 063f77a193d0c..879acc737e75b 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -107,7 +107,7 @@ type Options struct { TrialGenerator func(context.Context, string) error TemplateScheduleStore schedule.TemplateScheduleStore - HealthcheckFunc func(ctx context.Context) (*healthcheck.Report, error) + HealthcheckFunc func(ctx context.Context, apiKey string) (*healthcheck.Report, error) HealthcheckTimeout time.Duration HealthcheckRefresh time.Duration diff --git a/coderd/debug.go b/coderd/debug.go index 3bad452bdd296..ef9a0018210d1 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -7,6 +7,7 @@ import ( "github.com/coder/coder/coderd/healthcheck" "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/codersdk" ) @@ -29,11 +30,28 @@ func (api *API) debugCoordinator(rw http.ResponseWriter, r *http.Request) { // @Success 200 {object} healthcheck.Report // @Router /debug/health [get] func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { + apiKey := httpmw.APITokenFromRequest(r) ctx, cancel := context.WithTimeout(r.Context(), api.HealthcheckTimeout) defer cancel() + // Get cached report if it exists. + if report := api.healthCheckCache.Load(); report != nil { + if time.Since(report.Time) < api.HealthcheckRefresh { + httpapi.Write(ctx, rw, http.StatusOK, report) + return + } + } + resChan := api.healthCheckGroup.DoChan("", func() (*healthcheck.Report, error) { - return api.HealthcheckFunc(ctx) + // Create a new context not tied to the request. + ctx, cancel := context.WithTimeout(context.Background(), api.HealthcheckTimeout) + defer cancel() + + report, err := api.HealthcheckFunc(ctx, apiKey) + if err == nil { + api.healthCheckCache.Store(report) + } + return report, err }) select { @@ -43,13 +61,19 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { }) return case res := <-resChan: - if time.Since(res.Val.Time) > api.HealthcheckRefresh { - api.healthCheckGroup.Forget("") - api.debugDeploymentHealth(rw, r) - return - } - httpapi.Write(ctx, rw, http.StatusOK, res.Val) return } } + +// For some reason the swagger docs need to be attached to a function. +// +// @Summary Debug Info Websocket Test +// @ID debug-info-websocket-test +// @Security CoderSessionToken +// @Produce json +// @Tags Debug +// @Success 201 {object} codersdk.Response +// @Router /debug/ws [get] +// @x-apidocgen {"skip": true} +func _debugws(http.ResponseWriter, *http.Request) {} //nolint:unused diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 81022a10c25b1..9742ce959834b 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" @@ -14,15 +15,17 @@ import ( "github.com/coder/coder/testutil" ) -func TestDebug(t *testing.T) { +func TestDebugHealth(t *testing.T) { t.Parallel() - t.Run("Health/OK", func(t *testing.T) { + t.Run("OK", func(t *testing.T) { t.Parallel() var ( - ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort) - client = coderdtest.New(t, &coderdtest.Options{ - HealthcheckFunc: func(context.Context) (*healthcheck.Report, error) { + ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort) + sessionToken string + client = coderdtest.New(t, &coderdtest.Options{ + HealthcheckFunc: func(_ context.Context, apiKey string) (*healthcheck.Report, error) { + assert.Equal(t, sessionToken, apiKey) return &healthcheck.Report{}, nil }, }) @@ -30,6 +33,7 @@ func TestDebug(t *testing.T) { ) defer cancel() + sessionToken = client.SessionToken() res, err := client.Request(ctx, "GET", "/debug/health", nil) require.NoError(t, err) defer res.Body.Close() @@ -37,14 +41,14 @@ func TestDebug(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) }) - t.Run("Health/Timeout", func(t *testing.T) { + t.Run("Timeout", func(t *testing.T) { t.Parallel() var ( ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort) client = coderdtest.New(t, &coderdtest.Options{ HealthcheckTimeout: time.Microsecond, - HealthcheckFunc: func(context.Context) (*healthcheck.Report, error) { + HealthcheckFunc: func(context.Context, string) (*healthcheck.Report, error) { t := time.NewTimer(time.Second) defer t.Stop() @@ -66,4 +70,48 @@ func TestDebug(t *testing.T) { _, _ = io.ReadAll(res.Body) require.Equal(t, http.StatusNotFound, res.StatusCode) }) + + t.Run("Deduplicated", func(t *testing.T) { + t.Parallel() + + var ( + ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitShort) + calls int + client = coderdtest.New(t, &coderdtest.Options{ + HealthcheckRefresh: time.Hour, + HealthcheckTimeout: time.Hour, + HealthcheckFunc: func(context.Context, string) (*healthcheck.Report, error) { + calls++ + return &healthcheck.Report{ + Time: time.Now(), + }, nil + }, + }) + _ = coderdtest.CreateFirstUser(t, client) + ) + defer cancel() + + res, err := client.Request(ctx, "GET", "/api/v2/debug/health", nil) + require.NoError(t, err) + defer res.Body.Close() + _, _ = io.ReadAll(res.Body) + + require.Equal(t, http.StatusOK, res.StatusCode) + + res, err = client.Request(ctx, "GET", "/api/v2/debug/health", nil) + require.NoError(t, err) + defer res.Body.Close() + _, _ = io.ReadAll(res.Body) + + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, 1, calls) + }) +} + +func TestDebugWebsocket(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + }) } diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go index 88f9f0ad075d0..cc5def5719c1d 100644 --- a/coderd/healthcheck/healthcheck.go +++ b/coderd/healthcheck/healthcheck.go @@ -19,9 +19,7 @@ type Report struct { DERP DERPReport `json:"derp"` AccessURL AccessURLReport `json:"access_url"` - - // TODO: - // Websocket WebsocketReport `json:"websocket"` + Websocket WebsocketReport `json:"websocket"` } type ReportOptions struct { @@ -29,6 +27,7 @@ type ReportOptions struct { DERPMap *tailcfg.DERPMap AccessURL *url.URL Client *http.Client + APIKey string } func Run(ctx context.Context, opts *ReportOptions) (*Report, error) { @@ -65,11 +64,19 @@ func Run(ctx context.Context, opts *ReportOptions) (*Report, error) { }) }() - // wg.Add(1) - // go func() { - // defer wg.Done() - // report.Websocket.Run(ctx, opts.AccessURL) - // }() + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + report.Websocket.Error = xerrors.Errorf("%v", err) + } + }() + report.Websocket.Run(ctx, &WebsocketReportOptions{ + APIKey: opts.APIKey, + AccessURL: opts.AccessURL, + }) + }() wg.Wait() report.Time = time.Now() diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go index a8063f3f6a9ee..cbab40e054e79 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -2,11 +2,149 @@ package healthcheck import ( "context" + "io" + "net/http" "net/url" + "strconv" + "time" + + "golang.org/x/xerrors" + "nhooyr.io/websocket" + + "github.com/coder/coder/coderd/httpapi" ) -type WebsocketReport struct{} +type WebsocketReportOptions struct { + APIKey string + AccessURL *url.URL + HTTPClient *http.Client +} + +type WebsocketReport struct { + Response WebsocketResponse `json:"response"` + Error error `json:"error"` +} + +type WebsocketResponse struct { + Body string `json:"body"` + Code int `json:"code"` +} + +func (r *WebsocketReport) Run(ctx context.Context, opts *WebsocketReportOptions) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + u, err := opts.AccessURL.Parse("/api/v2/debug/ws") + if err != nil { + r.Error = xerrors.Errorf("parse access url: %w", err) + return + } + if u.Scheme == "https" { + u.Scheme = "wss" + } else { + u.Scheme = "ws" + } + + //nolint:bodyclose // websocket package closes this for you + c, res, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + HTTPClient: opts.HTTPClient, + HTTPHeader: http.Header{"Coder-Session-Token": []string{opts.APIKey}}, + }) + if res != nil { + var body string + if res.Body != nil { + b, err := io.ReadAll(res.Body) + if err == nil { + body = string(b) + } + } + + r.Response = WebsocketResponse{ + Body: body, + Code: res.StatusCode, + } + } + if err != nil { + r.Error = xerrors.Errorf("websocket dial: %w", err) + return + } + defer c.Close(websocket.StatusGoingAway, "goodbye") + + for i := 0; i < 3; i++ { + msg := strconv.Itoa(i) + err := c.Write(ctx, websocket.MessageText, []byte(msg)) + if err != nil { + r.Error = xerrors.Errorf("write message: %w", err) + return + } + + ty, got, err := c.Read(ctx) + if err != nil { + r.Error = xerrors.Errorf("read message: %w", err) + return + } + + if ty != websocket.MessageText { + r.Error = xerrors.Errorf("received incorrect message type: %v", ty) + return + } + + if string(got) != msg { + r.Error = xerrors.Errorf("received incorrect message: wanted %q, got %q", msg, string(got)) + return + } + } + + c.Close(websocket.StatusGoingAway, "goodbye") +} + +type WebsocketEchoServer struct { + Error error + Code int +} + +func (s *WebsocketEchoServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + if s.Error != nil { + rw.WriteHeader(s.Code) + _, _ = rw.Write([]byte(s.Error.Error())) + return + } + + ctx := r.Context() + c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, "unable to accept: "+err.Error()) + return + } + defer c.Close(websocket.StatusGoingAway, "goodbye") + + echo := func() error { + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + + typ, r, err := c.Reader(ctx) + if err != nil { + return xerrors.Errorf("get reader: %w", err) + } + + w, err := c.Writer(ctx, typ) + if err != nil { + return xerrors.Errorf("get writer: %w", err) + } + + _, err = io.Copy(w, r) + if err != nil { + return xerrors.Errorf("echo message: %w", err) + } + + err = w.Close() + return err + } -func (*WebsocketReport) Run(ctx context.Context, accessURL *url.URL) { - _, _ = ctx, accessURL + for { + err := echo() + if err != nil { + return + } + } } diff --git a/coderd/healthcheck/websocket_test.go b/coderd/healthcheck/websocket_test.go new file mode 100644 index 0000000000000..6b5f17fc24c2b --- /dev/null +++ b/coderd/healthcheck/websocket_test.go @@ -0,0 +1,69 @@ +package healthcheck_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/healthcheck" + "github.com/coder/coder/testutil" +) + +func TestWebsocket(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(&healthcheck.WebsocketEchoServer{}) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + wsReport := healthcheck.WebsocketReport{} + wsReport.Run(ctx, &healthcheck.WebsocketReportOptions{ + AccessURL: u, + HTTPClient: srv.Client(), + APIKey: "test", + }) + + require.NoError(t, wsReport.Error) + }) + + t.Run("Error", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(&healthcheck.WebsocketEchoServer{ + Error: xerrors.New("test error"), + Code: http.StatusBadRequest, + }) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + wsReport := healthcheck.WebsocketReport{} + wsReport.Run(ctx, &healthcheck.WebsocketReportOptions{ + AccessURL: u, + HTTPClient: srv.Client(), + APIKey: "test", + }) + + require.Error(t, wsReport.Error) + assert.Equal(t, wsReport.Response.Body, "test error") + assert.Equal(t, wsReport.Response.Code, http.StatusBadRequest) + }) +} diff --git a/docs/api/debug.md b/docs/api/debug.md index 0f68215501c4e..d425cb4a5dd13 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -207,7 +207,14 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ } }, "pass": true, - "time": "string" + "time": "string", + "websocket": { + "error": null, + "response": { + "body": "string", + "code": 0 + } + } } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b613608e555e2..914e98cd84aef 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -6393,7 +6393,14 @@ Parameter represents a set value for the scope. } }, "pass": true, - "time": "string" + "time": "string", + "websocket": { + "error": null, + "response": { + "body": "string", + "code": 0 + } + } } ``` @@ -6405,6 +6412,42 @@ Parameter represents a set value for the scope. | `derp` | [healthcheck.DERPReport](#healthcheckderpreport) | false | | | | `pass` | boolean | false | | Healthy is true if the report returns no errors. | | `time` | string | false | | Time is the time the report was generated at. | +| `websocket` | [healthcheck.WebsocketReport](#healthcheckwebsocketreport) | false | | | + +## healthcheck.WebsocketReport + +```json +{ + "error": null, + "response": { + "body": "string", + "code": 0 + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | -------------------------------------------------------------- | -------- | ------------ | ----------- | +| `error` | any | false | | | +| `response` | [healthcheck.WebsocketResponse](#healthcheckwebsocketresponse) | false | | | + +## healthcheck.WebsocketResponse + +```json +{ + "body": "string", + "code": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------ | ------- | -------- | ------------ | ----------- | +| `body` | string | false | | | +| `code` | integer | false | | | ## netcheck.Report