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
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: