mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: manage health settings using Coder API (#10861)
This commit is contained in:
Generated
+86
@@ -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": [
|
||||
@@ -8904,6 +8966,17 @@ const docTemplate = `{
|
||||
"GroupSourceOIDC"
|
||||
]
|
||||
},
|
||||
"codersdk.HealthSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dismissed_healthchecks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Healthcheck": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9851,6 +9924,7 @@ const docTemplate = `{
|
||||
"group",
|
||||
"license",
|
||||
"convert_login",
|
||||
"health_settings",
|
||||
"workspace_proxy",
|
||||
"organization"
|
||||
],
|
||||
@@ -9865,6 +9939,7 @@ const docTemplate = `{
|
||||
"ResourceTypeGroup",
|
||||
"ResourceTypeLicense",
|
||||
"ResourceTypeConvertLogin",
|
||||
"ResourceTypeHealthSettings",
|
||||
"ResourceTypeWorkspaceProxy",
|
||||
"ResourceTypeOrganization"
|
||||
]
|
||||
@@ -10771,6 +10846,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateHealthSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dismissed_healthchecks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateRoles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+76
@@ -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": [
|
||||
@@ -8000,6 +8052,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": {
|
||||
@@ -8877,6 +8940,7 @@
|
||||
"group",
|
||||
"license",
|
||||
"convert_login",
|
||||
"health_settings",
|
||||
"workspace_proxy",
|
||||
"organization"
|
||||
],
|
||||
@@ -8891,6 +8955,7 @@
|
||||
"ResourceTypeGroup",
|
||||
"ResourceTypeLicense",
|
||||
"ResourceTypeConvertLogin",
|
||||
"ResourceTypeHealthSettings",
|
||||
"ResourceTypeWorkspaceProxy",
|
||||
"ResourceTypeOrganization"
|
||||
]
|
||||
@@ -9754,6 +9819,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateHealthSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dismissed_healthchecks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UpdateRoles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+7
-1
@@ -970,7 +970,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)
|
||||
r.Route("/{user}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractUserParam(options.Database))
|
||||
|
||||
Generated
+2
-1
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Nothing to do
|
||||
@@ -0,0 +1,2 @@
|
||||
-- This has to be outside a transaction
|
||||
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'health_settings';
|
||||
@@ -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 {
|
||||
@@ -1208,7 +1209,8 @@ func (e ResourceType) Valid() bool {
|
||||
ResourceTypeWorkspaceBuild,
|
||||
ResourceTypeLicense,
|
||||
ResourceTypeWorkspaceProxy,
|
||||
ResourceTypeConvertLogin:
|
||||
ResourceTypeConvertLogin,
|
||||
ResourceTypeHealthSettings:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -1228,6 +1230,7 @@ func AllResourceTypeValues() []ResourceType {
|
||||
ResourceTypeLicense,
|
||||
ResourceTypeWorkspaceProxy,
|
||||
ResourceTypeConvertLogin,
|
||||
ResourceTypeHealthSettings,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+135
@@ -1,14 +1,24 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -107,6 +117,131 @@ func formatHealthcheck(ctx context.Context, rw http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
// @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) {
|
||||
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
|
||||
// @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) {
|
||||
ctx := r.Context()
|
||||
|
||||
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentValues) {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Insufficient permissions to update health settings.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var settings codersdk.HealthSettings
|
||||
if !httpapi.Read(ctx, rw, r, &settings) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
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.
|
||||
//
|
||||
// @Summary Debug Info Websocket Test
|
||||
|
||||
@@ -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,108 @@ 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("DismissSection", 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: dismiss "derp" and "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("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()
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user