From eb4fd94274725fc430f7c93bac3ea06a69922a78 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 24 Nov 2023 16:34:36 +0100 Subject: [PATCH 1/9] Stub for debug/health/settings --- coderd/coderd.go | 8 +++++++- coderd/debug.go | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 8b54b708cc85d..d3888ced6181b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -956,7 +956,13 @@ func New(options *Options) *API { r.Get("/coordinator", api.debugCoordinator) r.Get("/tailnet", api.debugTailnet) - r.Get("/health", api.debugDeploymentHealth) + r.Route("/health", func(r chi.Router) { + r.Get("/", api.debugDeploymentHealth) + r.Route("/settings", func(r chi.Router) { + r.Get("/", api.deploymentHealthSettings) + r.Put("/", api.putDeploymentHealthSettings) + }) + }) r.Get("/ws", (&healthcheck.WebsocketEchoServer{}).ServeHTTP) }) }) diff --git a/coderd/debug.go b/coderd/debug.go index ba6eaf9696d99..b5a057e61e0d3 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -82,6 +82,30 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { } } +// @Summary Get health settings +// @ID get-health-settings +// @Security CoderSessionToken +// @Produce json +// @Tags Debug +// @Success 200 {object} codersdk.HealthSettings +// @Router /debug/health/settings [get] +func (api *API) deploymentHealthSettings(rw http.ResponseWriter, r *http.Request) { + // TODO +} + +// @Summary Update health settings +// @ID update-health-settings +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Debug +// @Param request body codersdk.UpdateHealthSettings true "Update health settings" +// @Success 200 {object} codersdk.UpdateHealthSettings +// @Router /debug/health/settings [put] +func (api *API) putDeploymentHealthSettings(rw http.ResponseWriter, r *http.Request) { + // TODO +} + func formatHealthcheck(ctx context.Context, rw http.ResponseWriter, r *http.Request, hc *healthcheck.Report) { format := r.URL.Query().Get("format") switch format { From a820c1bc25d9dab4dd8af0d867b2850281cbc153 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 24 Nov 2023 17:17:39 +0100 Subject: [PATCH 2/9] stub for HealthSettings API --- coderd/apidoc/docs.go | 84 ++++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 74 ++++++++++++++++++++++++++++++ codersdk/health.go | 9 ++++ docs/api/debug.md | 77 +++++++++++++++++++++++++++++++ docs/api/schemas.md | 28 ++++++++++++ site/src/api/typesGenerated.ts | 10 ++++ 6 files changed, 282 insertions(+) create mode 100644 codersdk/health.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ed7968577b455..0fc8905931244 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -430,6 +430,68 @@ const docTemplate = `{ } } }, + "/debug/health/settings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Debug" + ], + "summary": "Get health settings", + "operationId": "get-health-settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.HealthSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Debug" + ], + "summary": "Update health settings", + "operationId": "update-health-settings", + "parameters": [ + { + "description": "Update health settings", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateHealthSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UpdateHealthSettings" + } + } + } + } + }, "/debug/tailnet": { "get": { "security": [ @@ -8873,6 +8935,17 @@ const docTemplate = `{ "GroupSourceOIDC" ] }, + "codersdk.HealthSettings": { + "type": "object", + "properties": { + "dismissed_healthchecks": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.Healthcheck": { "type": "object", "properties": { @@ -10738,6 +10811,17 @@ const docTemplate = `{ } } }, + "codersdk.UpdateHealthSettings": { + "type": "object", + "properties": { + "dismissed_healthchecks": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.UpdateRoles": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f92e11b92609f..776ed4c20f7ce 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -366,6 +366,58 @@ } } }, + "/debug/health/settings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Debug"], + "summary": "Get health settings", + "operationId": "get-health-settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.HealthSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Debug"], + "summary": "Update health settings", + "operationId": "update-health-settings", + "parameters": [ + { + "description": "Update health settings", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateHealthSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UpdateHealthSettings" + } + } + } + } + }, "/debug/tailnet": { "get": { "security": [ @@ -7971,6 +8023,17 @@ "enum": ["user", "oidc"], "x-enum-varnames": ["GroupSourceUser", "GroupSourceOIDC"] }, + "codersdk.HealthSettings": { + "type": "object", + "properties": { + "dismissed_healthchecks": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.Healthcheck": { "type": "object", "properties": { @@ -9723,6 +9786,17 @@ } } }, + "codersdk.UpdateHealthSettings": { + "type": "object", + "properties": { + "dismissed_healthchecks": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.UpdateRoles": { "type": "object", "properties": { diff --git a/codersdk/health.go b/codersdk/health.go new file mode 100644 index 0000000000000..9f1d869b91ee7 --- /dev/null +++ b/codersdk/health.go @@ -0,0 +1,9 @@ +package codersdk + +type HealthSettings struct { + DismissedHealthchecks []string `json:"dismissed_healthchecks"` +} + +type UpdateHealthSettings struct { + DismissedHealthchecks []string `json:"dismissed_healthchecks"` +} diff --git a/docs/api/debug.md b/docs/api/debug.md index c2a26321716d3..6ba3ce3c37bd5 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -265,6 +265,83 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get health settings + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/debug/health/settings \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /debug/health/settings` + +### Example responses + +> 200 Response + +```json +{ + "dismissed_healthchecks": ["string"] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.HealthSettings](schemas.md#codersdkhealthsettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update health settings + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/debug/health/settings \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /debug/health/settings` + +> Body parameter + +```json +{ + "dismissed_healthchecks": ["string"] +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------------------------------------------------------------------ | -------- | ---------------------- | +| `body` | body | [codersdk.UpdateHealthSettings](schemas.md#codersdkupdatehealthsettings) | true | Update health settings | + +### Example responses + +> 200 Response + +```json +{ + "dismissed_healthchecks": ["string"] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UpdateHealthSettings](schemas.md#codersdkupdatehealthsettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Debug Info Tailnet ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c4dc42883987d..af9c9da91954e 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3166,6 +3166,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `user` | | `oidc` | +## codersdk.HealthSettings + +```json +{ + "dismissed_healthchecks": ["string"] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------------ | --------------- | -------- | ------------ | ----------- | +| `dismissed_healthchecks` | array of string | false | | | + ## codersdk.Healthcheck ```json @@ -5157,6 +5171,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `url` | string | false | | URL to download the latest release of Coder. | | `version` | string | false | | Version is the semantic version for the latest release of Coder. | +## codersdk.UpdateHealthSettings + +```json +{ + "dismissed_healthchecks": ["string"] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------------ | --------------- | -------- | ------------ | ----------- | +| `dismissed_healthchecks` | array of string | false | | | + ## codersdk.UpdateRoles ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ccf06da3f8b47..76e8c2dce5198 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -542,6 +542,11 @@ export interface Group { readonly source: GroupSource; } +// From codersdk/health.go +export interface HealthSettings { + readonly dismissed_healthchecks: string[]; +} + // From codersdk/workspaceapps.go export interface Healthcheck { readonly url: string; @@ -1155,6 +1160,11 @@ export interface UpdateCheckResponse { readonly url: string; } +// From codersdk/health.go +export interface UpdateHealthSettings { + readonly dismissed_healthchecks: string[]; +} + // From codersdk/users.go export interface UpdateRoles { readonly roles: string[]; From a01878ecce9e632ebd146741e9ab0e0e2d25a41b Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 11:41:03 +0100 Subject: [PATCH 3/9] API --- coderd/debug.go | 120 ++++++++++++++++++++++++------ coderd/healthcheck/healthcheck.go | 2 + 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/coderd/debug.go b/coderd/debug.go index b5a057e61e0d3..37aae1042a7a9 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "encoding/json" "fmt" "net/http" "time" @@ -9,7 +10,10 @@ import ( "github.com/coder/coder/v2/coderd/healthcheck" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" + "golang.org/x/exp/slices" + "golang.org/x/xerrors" ) // @Summary Debug Info Wireguard Coordinator @@ -82,6 +86,31 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { } } +func formatHealthcheck(ctx context.Context, rw http.ResponseWriter, r *http.Request, hc *healthcheck.Report) { + format := r.URL.Query().Get("format") + switch format { + case "text": + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + rw.WriteHeader(http.StatusOK) + + _, _ = fmt.Fprintln(rw, "time:", hc.Time.Format(time.RFC3339)) + _, _ = fmt.Fprintln(rw, "healthy:", hc.Healthy) + _, _ = fmt.Fprintln(rw, "derp:", hc.DERP.Healthy) + _, _ = fmt.Fprintln(rw, "access_url:", hc.AccessURL.Healthy) + _, _ = fmt.Fprintln(rw, "websocket:", hc.Websocket.Healthy) + _, _ = fmt.Fprintln(rw, "database:", hc.Database.Healthy) + + case "", "json": + httpapi.WriteIndent(ctx, rw, http.StatusOK, hc) + + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid format option %q.", format), + Detail: "Allowed values are: \"json\", \"simple\".", + }) + } +} + // @Summary Get health settings // @ID get-health-settings // @Security CoderSessionToken @@ -90,7 +119,30 @@ func (api *API) debugDeploymentHealth(rw http.ResponseWriter, r *http.Request) { // @Success 200 {object} codersdk.HealthSettings // @Router /debug/health/settings [get] func (api *API) deploymentHealthSettings(rw http.ResponseWriter, r *http.Request) { - // TODO + settingsJSON, err := api.Database.GetHealthSettings(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch health settings.", + Detail: err.Error(), + }) + return + } + + var settings codersdk.HealthSettings + err = json.Unmarshal([]byte(settingsJSON), &settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal health settings.", + Detail: err.Error(), + }) + return + } + + if len(settings.DismissedHealthchecks) == 0 { + settings.DismissedHealthchecks = []string{} + } + + httpapi.Write(r.Context(), rw, http.StatusOK, settings) } // @Summary Update health settings @@ -103,32 +155,58 @@ func (api *API) deploymentHealthSettings(rw http.ResponseWriter, r *http.Request // @Success 200 {object} codersdk.UpdateHealthSettings // @Router /debug/health/settings [put] func (api *API) putDeploymentHealthSettings(rw http.ResponseWriter, r *http.Request) { - // TODO -} + ctx := r.Context() -func formatHealthcheck(ctx context.Context, rw http.ResponseWriter, r *http.Request, hc *healthcheck.Report) { - format := r.URL.Query().Get("format") - switch format { - case "text": - rw.Header().Set("Content-Type", "text/plain; charset=utf-8") - rw.WriteHeader(http.StatusOK) + if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentValues) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Insufficient permissions to update health settings.", + }) + return + } - _, _ = fmt.Fprintln(rw, "time:", hc.Time.Format(time.RFC3339)) - _, _ = fmt.Fprintln(rw, "healthy:", hc.Healthy) - _, _ = fmt.Fprintln(rw, "derp:", hc.DERP.Healthy) - _, _ = fmt.Fprintln(rw, "access_url:", hc.AccessURL.Healthy) - _, _ = fmt.Fprintln(rw, "websocket:", hc.Websocket.Healthy) - _, _ = fmt.Fprintln(rw, "database:", hc.Database.Healthy) + var settings codersdk.HealthSettings + if !httpapi.Read(ctx, rw, r, &settings) { + return + } - case "", "json": - httpapi.WriteIndent(ctx, rw, http.StatusOK, hc) + err := validateHealthSettings(settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to validate health settings.", + Detail: err.Error(), + }) + return + } - default: - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Invalid format option %q.", format), - Detail: "Allowed values are: \"json\", \"simple\".", + settingsJSON, err := json.Marshal(&settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal health settings.", + Detail: err.Error(), + }) + return + } + + err = api.Database.UpsertHealthSettings(ctx, string(settingsJSON)) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update health settings.", + Detail: err.Error(), }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} + +func validateHealthSettings(settings codersdk.HealthSettings) error { + for _, dismissed := range settings.DismissedHealthchecks { + ok := slices.Contains(healthcheck.Sections, dismissed) + if !ok { + return xerrors.Errorf("unknown healthcheck section: %s", dismissed) + } } + return nil } // For some reason the swagger docs need to be attached to a function. diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go index 9233626419634..9ecb9b9d13a44 100644 --- a/coderd/healthcheck/healthcheck.go +++ b/coderd/healthcheck/healthcheck.go @@ -20,6 +20,8 @@ const ( SectionWorkspaceProxy string = "WorkspaceProxy" ) +var Sections = []string{SectionAccessURL, SectionDatabase, SectionDERP, SectionWebsocket, SectionWorkspaceProxy} + type Checker interface { DERP(ctx context.Context, opts *derphealth.ReportOptions) derphealth.Report AccessURL(ctx context.Context, opts *AccessURLReportOptions) AccessURLReport From 36b8c1c209d650a126ded5d98e59fd43ee8a835e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 12:34:29 +0100 Subject: [PATCH 4/9] API tests --- coderd/debug.go | 5 +++-- coderd/debug_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++ codersdk/health.go | 31 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/coderd/debug.go b/coderd/debug.go index 37aae1042a7a9..4165914be0a3f 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -7,13 +7,14 @@ import ( "net/http" "time" + "golang.org/x/exp/slices" + "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/healthcheck" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" - "golang.org/x/exp/slices" - "golang.org/x/xerrors" ) // @Summary Debug Info Wireguard Coordinator diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 186539b82d90f..59ebd3d3a8af4 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/healthcheck" "github.com/coder/coder/v2/coderd/healthcheck/derphealth" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -232,6 +233,52 @@ func TestDebugHealth(t *testing.T) { }) } +func TestHealthSettings(t *testing.T) { + t.Parallel() + + t.Run("InitialState", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // given + adminClient := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, adminClient) + + // when + settings, err := adminClient.HealthSettings(ctx) + require.NoError(t, err) + + // then + require.Equal(t, codersdk.HealthSettings{DismissedHealthchecks: []string{}}, settings) + }) + + t.Run("Updated", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // given + adminClient := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, adminClient) + + expected := codersdk.HealthSettings{ + DismissedHealthchecks: []string{healthcheck.SectionDERP, healthcheck.SectionWebsocket}, + } + + // when + err := adminClient.UpdateHealthSettings(ctx, expected) + require.NoError(t, err) + + // then + settings, err := adminClient.HealthSettings(ctx) + require.NoError(t, err) + require.Equal(t, expected, settings) + }) +} + func TestDebugWebsocket(t *testing.T) { t.Parallel() diff --git a/codersdk/health.go b/codersdk/health.go index 9f1d869b91ee7..47a421d6a0452 100644 --- a/codersdk/health.go +++ b/codersdk/health.go @@ -1,5 +1,11 @@ package codersdk +import ( + "context" + "encoding/json" + "net/http" +) + type HealthSettings struct { DismissedHealthchecks []string `json:"dismissed_healthchecks"` } @@ -7,3 +13,28 @@ type HealthSettings struct { type UpdateHealthSettings struct { DismissedHealthchecks []string `json:"dismissed_healthchecks"` } + +func (c *Client) HealthSettings(ctx context.Context) (HealthSettings, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/debug/health/settings", nil) + if err != nil { + return HealthSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return HealthSettings{}, ReadBodyAsError(res) + } + var settings HealthSettings + return settings, json.NewDecoder(res.Body).Decode(&settings) +} + +func (c *Client) UpdateHealthSettings(ctx context.Context, settings HealthSettings) error { + res, err := c.Request(ctx, http.MethodPut, "/api/v2/debug/health/settings", settings) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} From 19ab702a2cc6c316dc52606f17dc180b75c7e264 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 15:01:05 +0100 Subject: [PATCH 5/9] Update to Put --- coderd/debug_test.go | 2 +- codersdk/health.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 59ebd3d3a8af4..0bc6cddf06569 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -269,7 +269,7 @@ func TestHealthSettings(t *testing.T) { } // when - err := adminClient.UpdateHealthSettings(ctx, expected) + err := adminClient.PutHealthSettings(ctx, expected) require.NoError(t, err) // then diff --git a/codersdk/health.go b/codersdk/health.go index 47a421d6a0452..e1a90274c8808 100644 --- a/codersdk/health.go +++ b/codersdk/health.go @@ -27,7 +27,7 @@ func (c *Client) HealthSettings(ctx context.Context) (HealthSettings, error) { return settings, json.NewDecoder(res.Body).Decode(&settings) } -func (c *Client) UpdateHealthSettings(ctx context.Context, settings HealthSettings) error { +func (c *Client) PutHealthSettings(ctx context.Context, settings HealthSettings) error { res, err := c.Request(ctx, http.MethodPut, "/api/v2/debug/health/settings", settings) if err != nil { return err From 347a35497b1630eb01f5962948a23a922869c870 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 15:16:06 +0100 Subject: [PATCH 6/9] Not modified --- coderd/debug.go | 15 +++++++++++++++ coderd/debug_test.go | 25 +++++++++++++++++++++++++ codersdk/health.go | 5 +++++ 3 files changed, 45 insertions(+) diff --git a/coderd/debug.go b/coderd/debug.go index 4165914be0a3f..27062bc5f332b 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -1,6 +1,7 @@ package coderd import ( + "bytes" "context" "encoding/json" "fmt" @@ -188,6 +189,20 @@ func (api *API) putDeploymentHealthSettings(rw http.ResponseWriter, r *http.Requ return } + currentSettingsJSON, err := api.Database.GetHealthSettings(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch current health settings.", + Detail: err.Error(), + }) + return + } + + if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { + httpapi.Write(r.Context(), rw, http.StatusNotModified, nil) + return + } + err = api.Database.UpsertHealthSettings(ctx, string(settingsJSON)) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 0bc6cddf06569..0aa8d4d8ebde7 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -277,6 +277,31 @@ func TestHealthSettings(t *testing.T) { require.NoError(t, err) require.Equal(t, expected, settings) }) + + t.Run("NotModified", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // given + adminClient := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, adminClient) + + expected := codersdk.HealthSettings{ + DismissedHealthchecks: []string{healthcheck.SectionDERP, healthcheck.SectionWebsocket}, + } + + err := adminClient.PutHealthSettings(ctx, expected) + require.NoError(t, err) + + // when + err = adminClient.PutHealthSettings(ctx, expected) + + // then + require.Error(t, err) + require.Contains(t, err.Error(), "health settings not modified") + }) } func TestDebugWebsocket(t *testing.T) { diff --git a/codersdk/health.go b/codersdk/health.go index e1a90274c8808..ece12ba424771 100644 --- a/codersdk/health.go +++ b/codersdk/health.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + + "golang.org/x/xerrors" ) type HealthSettings struct { @@ -33,6 +35,9 @@ func (c *Client) PutHealthSettings(ctx context.Context, settings HealthSettings) return err } defer res.Body.Close() + if res.StatusCode == http.StatusNotModified { + return xerrors.New("health settings not modified") + } if res.StatusCode != http.StatusOK { return ReadBodyAsError(res) } From fb625d204a53d93b3a63e756edabd366d680839c Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 15:25:34 +0100 Subject: [PATCH 7/9] Undismiss --- coderd/debug_test.go | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/coderd/debug_test.go b/coderd/debug_test.go index 0aa8d4d8ebde7..855827c34d0f4 100644 --- a/coderd/debug_test.go +++ b/coderd/debug_test.go @@ -254,7 +254,7 @@ func TestHealthSettings(t *testing.T) { require.Equal(t, codersdk.HealthSettings{DismissedHealthchecks: []string{}}, settings) }) - t.Run("Updated", func(t *testing.T) { + t.Run("DismissSection", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -268,7 +268,7 @@ func TestHealthSettings(t *testing.T) { DismissedHealthchecks: []string{healthcheck.SectionDERP, healthcheck.SectionWebsocket}, } - // when + // when: dismiss "derp" and "websocket" err := adminClient.PutHealthSettings(ctx, expected) require.NoError(t, err) @@ -278,6 +278,37 @@ func TestHealthSettings(t *testing.T) { require.Equal(t, expected, settings) }) + t.Run("UnDismissSection", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + // given + adminClient := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, adminClient) + + initial := codersdk.HealthSettings{ + DismissedHealthchecks: []string{healthcheck.SectionDERP, healthcheck.SectionWebsocket}, + } + + err := adminClient.PutHealthSettings(ctx, initial) + require.NoError(t, err) + + expected := codersdk.HealthSettings{ + DismissedHealthchecks: []string{healthcheck.SectionDERP}, + } + + // when: undismiss "websocket" + err = adminClient.PutHealthSettings(ctx, expected) + require.NoError(t, err) + + // then + settings, err := adminClient.HealthSettings(ctx) + require.NoError(t, err) + require.Equal(t, expected, settings) + }) + t.Run("NotModified", func(t *testing.T) { t.Parallel() From c9ac036b4c954e602b7317f6167ae65dd8a8be55 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 15:56:43 +0100 Subject: [PATCH 8/9] Support auditing --- coderd/audit/diff.go | 3 ++- coderd/audit/request.go | 7 +++++++ coderd/database/models.go | 1 + coderd/database/types.go | 5 +++++ coderd/debug.go | 17 +++++++++++++++++ docs/admin/audit-logs.md | 1 + enterprise/audit/table.go | 4 ++++ 7 files changed, 37 insertions(+), 1 deletion(-) diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 8cf0a1d0ddaf3..bdaef00bb082b 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -18,7 +18,8 @@ type Auditable interface { database.AuditableGroup | database.License | database.WorkspaceProxy | - database.AuditOAuthConvertState + database.AuditOAuthConvertState | + database.HealthSettings } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/request.go b/coderd/audit/request.go index cc1f60779a7dc..6e738f9929bbb 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -93,6 +93,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.AuditOAuthConvertState: return string(typed.ToLoginType) + case database.HealthSettings: + return "" // no target? default: panic(fmt.Sprintf("unknown resource %T", tgt)) } @@ -123,6 +125,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { case database.AuditOAuthConvertState: // The merge state is for the given user return typed.UserID + case database.HealthSettings: + // Artificial ID for auditing purposes + return typed.ID default: panic(fmt.Sprintf("unknown resource %T", tgt)) } @@ -152,6 +157,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeWorkspaceProxy case database.AuditOAuthConvertState: return database.ResourceTypeConvertLogin + case database.HealthSettings: + return database.ResourceTypeHealthSettings default: panic(fmt.Sprintf("unknown resource %T", typed)) } diff --git a/coderd/database/models.go b/coderd/database/models.go index 3fe6c8fa12b87..4d97d5806667b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1158,6 +1158,7 @@ const ( ResourceTypeLicense ResourceType = "license" ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" ) func (e *ResourceType) Scan(src interface{}) error { diff --git a/coderd/database/types.go b/coderd/database/types.go index 5099733601f65..b1fb389d88794 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -23,6 +23,11 @@ type AuditOAuthConvertState struct { UserID uuid.UUID `db:"user_id" json:"user_id"` } +type HealthSettings struct { + ID uuid.UUID `db:"id" json:"id"` + DismissedHealthchecks []string `db:"dismissed_healthchecks" json:"dismissed_healthchecks"` +} + type Actions []rbac.Action func (a *Actions) Scan(src interface{}) error { diff --git a/coderd/debug.go b/coderd/debug.go index 27062bc5f332b..08916e1b4d37b 100644 --- a/coderd/debug.go +++ b/coderd/debug.go @@ -11,6 +11,10 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/healthcheck" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" @@ -203,6 +207,19 @@ func (api *API) putDeploymentHealthSettings(rw http.ResponseWriter, r *http.Requ return } + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.HealthSettings](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + aReq.New = database.HealthSettings{ + ID: uuid.New(), + DismissedHealthchecks: settings.DismissedHealthchecks, + } + err = api.Database.UpsertHealthSettings(ctx, string(settingsJSON)) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 09ad0aae5bc80..527134f88befb 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -14,6 +14,7 @@ We track the following resources: | AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 085f4ac581a9e..dcd775cf4c1f3 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -184,6 +184,10 @@ var auditableResourcesTypes = map[any]map[string]Action{ "to_login_type": ActionTrack, "user_id": ActionTrack, }, + &database.HealthSettings{}: { + "id": ActionIgnore, + "dismissed_healthchecks": ActionTrack, + }, // TODO: track an ID here when the below ticket is completed: // https://github.com/coder/coder/pull/6012 &database.License{}: { From b5acfd6cbddafbf9ac3853012d100fe6ca9b32c8 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 28 Nov 2023 16:11:46 +0100 Subject: [PATCH 9/9] db: resource_type health_settings --- coderd/apidoc/docs.go | 2 ++ coderd/apidoc/swagger.json | 2 ++ coderd/database/dump.sql | 3 ++- .../database/migrations/000172_health_settings_audit.down.sql | 1 + .../database/migrations/000172_health_settings_audit.up.sql | 2 ++ coderd/database/models.go | 4 +++- codersdk/audit.go | 3 +++ docs/api/schemas.md | 1 + site/src/api/typesGenerated.ts | 2 ++ 9 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 coderd/database/migrations/000172_health_settings_audit.down.sql create mode 100644 coderd/database/migrations/000172_health_settings_audit.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b05c61852103a..64ea030b1e0b7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9924,6 +9924,7 @@ const docTemplate = `{ "group", "license", "convert_login", + "health_settings", "workspace_proxy", "organization" ], @@ -9938,6 +9939,7 @@ const docTemplate = `{ "ResourceTypeGroup", "ResourceTypeLicense", "ResourceTypeConvertLogin", + "ResourceTypeHealthSettings", "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization" ] diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ecb07ade3dadb..1f6a8f07f5670 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8940,6 +8940,7 @@ "group", "license", "convert_login", + "health_settings", "workspace_proxy", "organization" ], @@ -8954,6 +8955,7 @@ "ResourceTypeGroup", "ResourceTypeLicense", "ResourceTypeConvertLogin", + "ResourceTypeHealthSettings", "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization" ] diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d65da03ed3448..f4200f7ea3109 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -133,7 +133,8 @@ CREATE TYPE resource_type AS ENUM ( 'workspace_build', 'license', 'workspace_proxy', - 'convert_login' + 'convert_login', + 'health_settings' ); CREATE TYPE startup_script_behavior AS ENUM ( diff --git a/coderd/database/migrations/000172_health_settings_audit.down.sql b/coderd/database/migrations/000172_health_settings_audit.down.sql new file mode 100644 index 0000000000000..362f597df0911 --- /dev/null +++ b/coderd/database/migrations/000172_health_settings_audit.down.sql @@ -0,0 +1 @@ +-- Nothing to do diff --git a/coderd/database/migrations/000172_health_settings_audit.up.sql b/coderd/database/migrations/000172_health_settings_audit.up.sql new file mode 100644 index 0000000000000..09dd8e17bfe0c --- /dev/null +++ b/coderd/database/migrations/000172_health_settings_audit.up.sql @@ -0,0 +1,2 @@ +-- This has to be outside a transaction +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'health_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 4d97d5806667b..bd7625657b7bd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1209,7 +1209,8 @@ func (e ResourceType) Valid() bool { ResourceTypeWorkspaceBuild, ResourceTypeLicense, ResourceTypeWorkspaceProxy, - ResourceTypeConvertLogin: + ResourceTypeConvertLogin, + ResourceTypeHealthSettings: return true } return false @@ -1229,6 +1230,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeLicense, ResourceTypeWorkspaceProxy, ResourceTypeConvertLogin, + ResourceTypeHealthSettings, } } diff --git a/codersdk/audit.go b/codersdk/audit.go index 5ceae81a21c42..c1ea077ec0831 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -24,6 +24,7 @@ const ( ResourceTypeGroup ResourceType = "group" ResourceTypeLicense ResourceType = "license" ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" ResourceTypeOrganization ResourceType = "organization" ) @@ -56,6 +57,8 @@ func (r ResourceType) FriendlyString() string { return "workspace proxy" case ResourceTypeOrganization: return "organization" + case ResourceTypeHealthSettings: + return "health_settings" default: return "unknown" } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 099dc864d2483..6de323c0968fe 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4176,6 +4176,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `group` | | `license` | | `convert_login` | +| `health_settings` | | `workspace_proxy` | | `organization` | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9eb04a4958c25..901f0c213bb02 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1930,6 +1930,7 @@ export type ResourceType = | "convert_login" | "git_ssh_key" | "group" + | "health_settings" | "license" | "organization" | "template" @@ -1943,6 +1944,7 @@ export const ResourceTypes: ResourceType[] = [ "convert_login", "git_ssh_key", "group", + "health_settings", "license", "organization", "template",