feat(healthcheck): add websocket report (#7689)

This commit is contained in:
Colin Adler 2023-05-30 14:22:32 -05:00 committed by GitHub
parent 77b0ca0b53
commit 022372dd73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 468 additions and 31 deletions

51
coderd/apidoc/docs.go generated
View File

@ -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": [
@ -10419,6 +10447,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"
}
}
},

View File

@ -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": [
@ -9408,6 +9432,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"
}
}
},

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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()
})
}

View File

@ -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()

View File

@ -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{}
func (*WebsocketReport) Run(ctx context.Context, accessURL *url.URL) {
_, _ = ctx, accessURL
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
}
for {
err := echo()
if err != nil {
return
}
}
}

View File

@ -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)
})
}

View File

@ -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
}
}
}
```

View File

@ -6377,7 +6377,14 @@ Parameter represents a set value for the scope.
}
},
"pass": true,
"time": "string"
"time": "string",
"websocket": {
"error": null,
"response": {
"body": "string",
"code": 0
}
}
}
```
@ -6389,6 +6396,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