From efbcf4375475ebd66db603063b98898e7f7b04c3 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Fri, 26 May 2023 00:06:50 +0000 Subject: [PATCH 1/8] feat(healthcheck): add websocket report --- coderd/coderd.go | 7 +- coderd/coderdtest/coderdtest.go | 2 +- coderd/debug.go | 26 ++++-- coderd/debug_test.go | 62 +++++++++++++-- coderd/healthcheck/healthcheck.go | 23 ++++-- coderd/healthcheck/websocket.go | 115 ++++++++++++++++++++++++++- coderd/healthcheck/websocket_test.go | 39 +++++++++ 7 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 coderd/healthcheck/websocket_test.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 712e52460fbfe..9a1fdd81c59fa 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..a95738d351a19 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,12 +61,6 @@ 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 } 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..1bdb72501c38f 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -2,11 +2,120 @@ 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 { + Error error `json:"error"` +} + +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 + c, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + HTTPClient: opts.HTTPClient, + HTTPHeader: http.Header{"Coder-Session-Token": []string{opts.APIKey}}, + }) + 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{} + +func (WebsocketEchoServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, "Invalid websocket.") + 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..c4408f8a262f1 --- /dev/null +++ b/coderd/healthcheck/websocket_test.go @@ -0,0 +1,39 @@ +package healthcheck_test + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "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) + }) +} From 4d989aace016350a1109b7d581f4cabb6cc07aca Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 16:19:08 +0000 Subject: [PATCH 2/8] fixup! feat(healthcheck): add websocket report --- coderd/apidoc/docs.go | 9 +++++++++ coderd/apidoc/swagger.json | 9 +++++++++ coderd/coderd.go | 7 +++++++ docs/api/debug.md | 5 ++++- docs/api/schemas.md | 20 +++++++++++++++++++- 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81675530aa57d..9c3cf7a7bd6f5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10425,9 +10425,18 @@ 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": {} + } + }, "netcheck.Report": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2c9080fe49b74..46395e27ab462 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9412,9 +9412,18 @@ "time": { "description": "Time is the time the report was generated at.", "type": "string" + }, + "websocket": { + "$ref": "#/definitions/healthcheck.WebsocketReport" } } }, + "healthcheck.WebsocketReport": { + "type": "object", + "properties": { + "error": {} + } + }, "netcheck.Report": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 9a1fdd81c59fa..e594f4388e7e4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -788,6 +788,13 @@ func New(options *Options) *API { r.Get("/coordinator", api.debugCoordinator) r.Get("/health", api.debugDeploymentHealth) + // @Summary Debug Info Deployment Health + // @ID debug-info-deployment-health + // @Security CoderSessionToken + // @Produce json + // @Tags Debug + // @Success 200 {object} healthcheck.Report + // @Router /debug/health [get] r.Get("/ws", healthcheck.WebsocketEchoServer{}.ServeHTTP) }) }) diff --git a/docs/api/debug.md b/docs/api/debug.md index 0f68215501c4e..41fe31e9dd4f5 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -207,7 +207,10 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ } }, "pass": true, - "time": "string" + "time": "string", + "websocket": { + "error": null + } } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b613608e555e2..0ade75c9d620b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -6393,7 +6393,10 @@ Parameter represents a set value for the scope. } }, "pass": true, - "time": "string" + "time": "string", + "websocket": { + "error": null + } } ``` @@ -6405,6 +6408,21 @@ 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 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------- | ---- | -------- | ------------ | ----------- | +| `error` | any | false | | | ## netcheck.Report From e3182d7adf4cb2d22a7867309019be6f7bed72de Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 17:20:55 +0000 Subject: [PATCH 3/8] fixup! feat(healthcheck): add websocket report --- coderd/apidoc/docs.go | 28 ++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 24 ++++++++++++++++++++++++ coderd/coderd.go | 7 ------- coderd/debug.go | 14 ++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9c3cf7a7bd6f5..62f9612ebee6e 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 Websocket", + "operationId": "debug-info-websocket-test", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/deployment/config": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 46395e27ab462..e511e6d59adcd 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 Websocket", + "operationId": "debug-info-websocket-test", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/deployment/config": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index e594f4388e7e4..9a1fdd81c59fa 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -788,13 +788,6 @@ func New(options *Options) *API { r.Get("/coordinator", api.debugCoordinator) r.Get("/health", api.debugDeploymentHealth) - // @Summary Debug Info Deployment Health - // @ID debug-info-deployment-health - // @Security CoderSessionToken - // @Produce json - // @Tags Debug - // @Success 200 {object} healthcheck.Report - // @Router /debug/health [get] r.Get("/ws", healthcheck.WebsocketEchoServer{}.ServeHTTP) }) }) diff --git a/coderd/debug.go b/coderd/debug.go index a95738d351a19..6c99e51ce3225 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -65,3 +65,17 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { return } } + +// @Summary Debug Websocket +// @ID debug-info-websocket-test +// @Security CoderSessionToken +// @Produce json +// @Tags Debug +// @Success 201 {object} codersdk.Response +// @Router /debug/ws [get] +// @x-apidocgen {"skip": true} +// +// For some reason the swagger docs need to be attached to a function. +// +//nolint:unused +func _debugws(http.ResponseWriter, *http.Request) {} From e86279c6fdf47804d9f87a52f138a999c52194fd Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 17:45:35 +0000 Subject: [PATCH 4/8] fixup! feat(healthcheck): add websocket report --- coderd/apidoc/docs.go | 14 ++++++++++---- coderd/apidoc/swagger.json | 14 ++++++++++---- coderd/debug.go | 2 +- coderd/healthcheck/websocket.go | 24 +++++++++++++++++++++--- docs/api/debug.md | 4 +++- docs/api/schemas.md | 16 +++++++++++----- 6 files changed, 56 insertions(+), 18 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 62f9612ebee6e..b25ed5deed007 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -440,11 +440,11 @@ const docTemplate = `{ "tags": [ "Debug" ], - "summary": "Debug Websocket", + "summary": "Debug Info Websocket Test", "operationId": "debug-info-websocket-test", "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { "$ref": "#/definitions/codersdk.Response" } @@ -10462,7 +10462,13 @@ const docTemplate = `{ "healthcheck.WebsocketReport": { "type": "object", "properties": { - "error": {} + "code": { + "type": "integer" + }, + "error": {}, + "response": { + "type": "string" + } } }, "netcheck.Report": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e511e6d59adcd..2130adccfa960 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -372,11 +372,11 @@ ], "produces": ["application/json"], "tags": ["Debug"], - "summary": "Debug Websocket", + "summary": "Debug Info Websocket Test", "operationId": "debug-info-websocket-test", "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { "$ref": "#/definitions/codersdk.Response" } @@ -9445,7 +9445,13 @@ "healthcheck.WebsocketReport": { "type": "object", "properties": { - "error": {} + "code": { + "type": "integer" + }, + "error": {}, + "response": { + "type": "string" + } } }, "netcheck.Report": { diff --git a/coderd/debug.go b/coderd/debug.go index 6c99e51ce3225..94b8138016a77 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -66,7 +66,7 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { } } -// @Summary Debug Websocket +// @Summary Debug Info Websocket Test // @ID debug-info-websocket-test // @Security CoderSessionToken // @Produce json diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go index 1bdb72501c38f..3f8d47ff0ebba 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -21,7 +21,13 @@ type WebsocketReportOptions struct { } type WebsocketReport struct { - Error error `json:"error"` + 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) { @@ -39,11 +45,23 @@ func (r *WebsocketReport) Run(ctx context.Context, opts *WebsocketReportOptions) u.Scheme = "ws" } - //nolint:bodyclose - c, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{ + //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 + 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 diff --git a/docs/api/debug.md b/docs/api/debug.md index 41fe31e9dd4f5..5854942464097 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -209,7 +209,9 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "pass": true, "time": "string", "websocket": { - "error": null + "code": 0, + "error": null, + "response": "string" } } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 0ade75c9d620b..0e8db6dfd5563 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -6395,7 +6395,9 @@ Parameter represents a set value for the scope. "pass": true, "time": "string", "websocket": { - "error": null + "code": 0, + "error": null, + "response": "string" } } ``` @@ -6414,15 +6416,19 @@ Parameter represents a set value for the scope. ```json { - "error": null + "code": 0, + "error": null, + "response": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------- | ---- | -------- | ------------ | ----------- | -| `error` | any | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------- | ------- | -------- | ------------ | ----------- | +| `code` | integer | false | | | +| `error` | any | false | | | +| `response` | string | false | | | ## netcheck.Report From ab3f2388e9cf06c97a606fbe6fe5d9192380d2eb Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 18:01:54 +0000 Subject: [PATCH 5/8] fixup! feat(healthcheck): add websocket report --- coderd/apidoc/docs.go | 14 +++++++++++--- coderd/apidoc/swagger.json | 14 +++++++++++--- coderd/debug.go | 8 +++----- docs/api/debug.md | 6 ++++-- docs/api/schemas.md | 37 ++++++++++++++++++++++++++++--------- 5 files changed, 57 insertions(+), 22 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b25ed5deed007..fb343ce01a2b4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10462,12 +10462,20 @@ const docTemplate = `{ "healthcheck.WebsocketReport": { "type": "object", "properties": { - "code": { - "type": "integer" - }, "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 2130adccfa960..e2ae6b5a19d89 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9445,12 +9445,20 @@ "healthcheck.WebsocketReport": { "type": "object", "properties": { - "code": { - "type": "integer" - }, "error": {}, "response": { + "$ref": "#/definitions/healthcheck.WebsocketResponse" + } + } + }, + "healthcheck.WebsocketResponse": { + "type": "object", + "properties": { + "body": { "type": "string" + }, + "code": { + "type": "integer" } } }, diff --git a/coderd/debug.go b/coderd/debug.go index 94b8138016a77..ef9a0018210d1 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -66,6 +66,8 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { } } +// 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 @@ -74,8 +76,4 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { // @Success 201 {object} codersdk.Response // @Router /debug/ws [get] // @x-apidocgen {"skip": true} -// -// For some reason the swagger docs need to be attached to a function. -// -//nolint:unused -func _debugws(http.ResponseWriter, *http.Request) {} +func _debugws(http.ResponseWriter, *http.Request) {} //nolint:unused diff --git a/docs/api/debug.md b/docs/api/debug.md index 5854942464097..d425cb4a5dd13 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -209,9 +209,11 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "pass": true, "time": "string", "websocket": { - "code": 0, "error": null, - "response": "string" + "response": { + "body": "string", + "code": 0 + } } } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 0e8db6dfd5563..914e98cd84aef 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -6395,9 +6395,11 @@ Parameter represents a set value for the scope. "pass": true, "time": "string", "websocket": { - "code": 0, "error": null, - "response": "string" + "response": { + "body": "string", + "code": 0 + } } } ``` @@ -6416,19 +6418,36 @@ Parameter represents a set value for the scope. ```json { - "code": 0, "error": null, - "response": "string" + "response": { + "body": "string", + "code": 0 + } } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | ------- | -------- | ------------ | ----------- | -| `code` | integer | false | | | -| `error` | any | false | | | -| `response` | string | false | | | +| 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 From 0ed839b5027f2c6783ffca61080513ca5b3cf60b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 18:15:52 +0000 Subject: [PATCH 6/8] fixup! feat(healthcheck): add websocket report --- coderd/healthcheck/websocket.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go index 3f8d47ff0ebba..02a64d59daccc 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -102,7 +102,7 @@ func (WebsocketEchoServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{}) if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, "Invalid websocket.") + httpapi.Write(ctx, rw, http.StatusBadRequest, "unable to accept: "+err.Error()) return } defer c.Close(websocket.StatusGoingAway, "goodbye") From b3eb880e93aa85c0ba2c58931f52fce7986101d5 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 18:16:34 +0000 Subject: [PATCH 7/8] fixup! feat(healthcheck): add websocket report --- coderd/healthcheck/websocket.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go index 02a64d59daccc..214126c713cae 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -52,9 +52,11 @@ func (r *WebsocketReport) Run(ctx context.Context, opts *WebsocketReportOptions) }) if res != nil { var body string - b, err := io.ReadAll(res.Body) - if err == nil { - body = string(b) + if res.Body != nil { + b, err := io.ReadAll(res.Body) + if err == nil { + body = string(b) + } } r.Response = WebsocketResponse{ From b1a09b4c6aee886ddf8e2d8655588cf1f6f87dcb Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 30 May 2023 19:06:47 +0000 Subject: [PATCH 8/8] fixup! feat(healthcheck): add websocket report --- coderd/coderd.go | 2 +- coderd/healthcheck/websocket.go | 13 +++++++++-- coderd/healthcheck/websocket_test.go | 32 +++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 9a1fdd81c59fa..d126f6bf63a68 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -788,7 +788,7 @@ func New(options *Options) *API { r.Get("/coordinator", api.debugCoordinator) r.Get("/health", api.debugDeploymentHealth) - r.Get("/ws", healthcheck.WebsocketEchoServer{}.ServeHTTP) + r.Get("/ws", (&healthcheck.WebsocketEchoServer{}).ServeHTTP) }) }) diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go index 214126c713cae..cbab40e054e79 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -98,9 +98,18 @@ func (r *WebsocketReport) Run(ctx context.Context, opts *WebsocketReportOptions) c.Close(websocket.StatusGoingAway, "goodbye") } -type WebsocketEchoServer struct{} +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 + } -func (WebsocketEchoServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{}) if err != nil { diff --git a/coderd/healthcheck/websocket_test.go b/coderd/healthcheck/websocket_test.go index c4408f8a262f1..6b5f17fc24c2b 100644 --- a/coderd/healthcheck/websocket_test.go +++ b/coderd/healthcheck/websocket_test.go @@ -2,11 +2,14 @@ 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" @@ -18,7 +21,7 @@ func TestWebsocket(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() - srv := httptest.NewServer(healthcheck.WebsocketEchoServer{}) + srv := httptest.NewServer(&healthcheck.WebsocketEchoServer{}) defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -36,4 +39,31 @@ func TestWebsocket(t *testing.T) { 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) + }) }