mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
feat: add health check monitoring to workspace apps (#4114)
This commit is contained in:
@ -414,8 +414,10 @@ func New(options *Options) *API {
|
||||
r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity)
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractWorkspaceAgent(options.Database))
|
||||
r.Get("/apps", api.workspaceAgentApps)
|
||||
r.Get("/metadata", api.workspaceAgentMetadata)
|
||||
r.Post("/version", api.postWorkspaceAgentVersion)
|
||||
r.Post("/app-health", api.postWorkspaceAppHealth)
|
||||
r.Get("/gitsshkey", api.agentGitSSHKey)
|
||||
r.Get("/coordinate", api.workspaceAgentCoordinate)
|
||||
r.Get("/report-stats", api.workspaceAgentReportStats)
|
||||
|
@ -57,10 +57,12 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
|
||||
"POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/apps": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/version": {NoAuthorize: true},
|
||||
"POST:/api/v2/workspaceagents/me/app-health": {NoAuthorize: true},
|
||||
"GET:/api/v2/workspaceagents/me/report-stats": {NoAuthorize: true},
|
||||
|
||||
// These endpoints have more assertions. This is good, add more endpoints to assert if you can!
|
||||
|
@ -2019,19 +2019,38 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW
|
||||
|
||||
// nolint:gosimple
|
||||
workspaceApp := database.WorkspaceApp{
|
||||
ID: arg.ID,
|
||||
AgentID: arg.AgentID,
|
||||
CreatedAt: arg.CreatedAt,
|
||||
Name: arg.Name,
|
||||
Icon: arg.Icon,
|
||||
Command: arg.Command,
|
||||
Url: arg.Url,
|
||||
RelativePath: arg.RelativePath,
|
||||
ID: arg.ID,
|
||||
AgentID: arg.AgentID,
|
||||
CreatedAt: arg.CreatedAt,
|
||||
Name: arg.Name,
|
||||
Icon: arg.Icon,
|
||||
Command: arg.Command,
|
||||
Url: arg.Url,
|
||||
RelativePath: arg.RelativePath,
|
||||
HealthcheckUrl: arg.HealthcheckUrl,
|
||||
HealthcheckInterval: arg.HealthcheckInterval,
|
||||
HealthcheckThreshold: arg.HealthcheckThreshold,
|
||||
Health: arg.Health,
|
||||
}
|
||||
q.workspaceApps = append(q.workspaceApps, workspaceApp)
|
||||
return workspaceApp, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateWorkspaceAppHealthByID(_ context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
for index, app := range q.workspaceApps {
|
||||
if app.ID != arg.ID {
|
||||
continue
|
||||
}
|
||||
app.Health = arg.Health
|
||||
q.workspaceApps[index] = app
|
||||
return nil
|
||||
}
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
13
coderd/database/dump.sql
generated
13
coderd/database/dump.sql
generated
@ -88,6 +88,13 @@ CREATE TYPE user_status AS ENUM (
|
||||
'suspended'
|
||||
);
|
||||
|
||||
CREATE TYPE workspace_app_health AS ENUM (
|
||||
'disabled',
|
||||
'initializing',
|
||||
'healthy',
|
||||
'unhealthy'
|
||||
);
|
||||
|
||||
CREATE TYPE workspace_transition AS ENUM (
|
||||
'start',
|
||||
'stop',
|
||||
@ -344,7 +351,11 @@ CREATE TABLE workspace_apps (
|
||||
icon character varying(256) NOT NULL,
|
||||
command character varying(65534),
|
||||
url character varying(65534),
|
||||
relative_path boolean DEFAULT false NOT NULL
|
||||
relative_path boolean DEFAULT false NOT NULL,
|
||||
healthcheck_url text DEFAULT ''::text NOT NULL,
|
||||
healthcheck_interval integer DEFAULT 0 NOT NULL,
|
||||
healthcheck_threshold integer DEFAULT 0 NOT NULL,
|
||||
health workspace_app_health DEFAULT 'disabled'::public.workspace_app_health NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_builds (
|
||||
|
@ -0,0 +1,7 @@
|
||||
ALTER TABLE ONLY workspace_apps
|
||||
DROP COLUMN IF EXISTS healthcheck_url,
|
||||
DROP COLUMN IF EXISTS healthcheck_interval,
|
||||
DROP COLUMN IF EXISTS healthcheck_threshold,
|
||||
DROP COLUMN IF EXISTS health;
|
||||
|
||||
DROP TYPE workspace_app_health;
|
@ -0,0 +1,7 @@
|
||||
CREATE TYPE workspace_app_health AS ENUM ('disabled', 'initializing', 'healthy', 'unhealthy');
|
||||
|
||||
ALTER TABLE ONLY workspace_apps
|
||||
ADD COLUMN IF NOT EXISTS healthcheck_url text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS healthcheck_interval int NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS healthcheck_threshold int NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS health workspace_app_health NOT NULL DEFAULT 'disabled';
|
@ -312,6 +312,27 @@ func (e *UserStatus) Scan(src interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorkspaceAppHealth string
|
||||
|
||||
const (
|
||||
WorkspaceAppHealthDisabled WorkspaceAppHealth = "disabled"
|
||||
WorkspaceAppHealthInitializing WorkspaceAppHealth = "initializing"
|
||||
WorkspaceAppHealthHealthy WorkspaceAppHealth = "healthy"
|
||||
WorkspaceAppHealthUnhealthy WorkspaceAppHealth = "unhealthy"
|
||||
)
|
||||
|
||||
func (e *WorkspaceAppHealth) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = WorkspaceAppHealth(s)
|
||||
case string:
|
||||
*e = WorkspaceAppHealth(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for WorkspaceAppHealth: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorkspaceTransition string
|
||||
|
||||
const (
|
||||
@ -576,14 +597,18 @@ type WorkspaceAgent struct {
|
||||
}
|
||||
|
||||
type WorkspaceApp struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Command sql.NullString `db:"command" json:"command"`
|
||||
Url sql.NullString `db:"url" json:"url"`
|
||||
RelativePath bool `db:"relative_path" json:"relative_path"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Command sql.NullString `db:"command" json:"command"`
|
||||
Url sql.NullString `db:"url" json:"url"`
|
||||
RelativePath bool `db:"relative_path" json:"relative_path"`
|
||||
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
|
||||
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
|
||||
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
|
||||
Health WorkspaceAppHealth `db:"health" json:"health"`
|
||||
}
|
||||
|
||||
type WorkspaceBuild struct {
|
||||
|
@ -149,6 +149,7 @@ type querier interface {
|
||||
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
|
||||
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
|
||||
UpdateWorkspaceAgentVersionByID(ctx context.Context, arg UpdateWorkspaceAgentVersionByIDParams) error
|
||||
UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error
|
||||
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
|
||||
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
|
||||
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
|
||||
|
@ -3849,7 +3849,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg Up
|
||||
}
|
||||
|
||||
const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE agent_id = $1 AND name = $2
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health FROM workspace_apps WHERE agent_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetWorkspaceAppByAgentIDAndNameParams struct {
|
||||
@ -3869,12 +3869,16 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg Ge
|
||||
&i.Command,
|
||||
&i.Url,
|
||||
&i.RelativePath,
|
||||
&i.HealthcheckUrl,
|
||||
&i.HealthcheckInterval,
|
||||
&i.HealthcheckThreshold,
|
||||
&i.Health,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) {
|
||||
@ -3895,6 +3899,10 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
|
||||
&i.Command,
|
||||
&i.Url,
|
||||
&i.RelativePath,
|
||||
&i.HealthcheckUrl,
|
||||
&i.HealthcheckInterval,
|
||||
&i.HealthcheckThreshold,
|
||||
&i.Health,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -3910,7 +3918,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
|
||||
}
|
||||
|
||||
const getWorkspaceAppsByAgentIDs = `-- name: GetWorkspaceAppsByAgentIDs :many
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) {
|
||||
@ -3931,6 +3939,10 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
|
||||
&i.Command,
|
||||
&i.Url,
|
||||
&i.RelativePath,
|
||||
&i.HealthcheckUrl,
|
||||
&i.HealthcheckInterval,
|
||||
&i.HealthcheckThreshold,
|
||||
&i.Health,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -3946,7 +3958,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
|
||||
}
|
||||
|
||||
const getWorkspaceAppsCreatedAfter = `-- name: GetWorkspaceAppsCreatedAfter :many
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC
|
||||
SELECT id, created_at, agent_id, name, icon, command, url, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) {
|
||||
@ -3967,6 +3979,10 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt
|
||||
&i.Command,
|
||||
&i.Url,
|
||||
&i.RelativePath,
|
||||
&i.HealthcheckUrl,
|
||||
&i.HealthcheckInterval,
|
||||
&i.HealthcheckThreshold,
|
||||
&i.Health,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -3991,21 +4007,29 @@ INSERT INTO
|
||||
icon,
|
||||
command,
|
||||
url,
|
||||
relative_path
|
||||
relative_path,
|
||||
healthcheck_url,
|
||||
healthcheck_interval,
|
||||
healthcheck_threshold,
|
||||
health
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, agent_id, name, icon, command, url, relative_path
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, agent_id, name, icon, command, url, relative_path, healthcheck_url, healthcheck_interval, healthcheck_threshold, health
|
||||
`
|
||||
|
||||
type InsertWorkspaceAppParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Command sql.NullString `db:"command" json:"command"`
|
||||
Url sql.NullString `db:"url" json:"url"`
|
||||
RelativePath bool `db:"relative_path" json:"relative_path"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
Command sql.NullString `db:"command" json:"command"`
|
||||
Url sql.NullString `db:"url" json:"url"`
|
||||
RelativePath bool `db:"relative_path" json:"relative_path"`
|
||||
HealthcheckUrl string `db:"healthcheck_url" json:"healthcheck_url"`
|
||||
HealthcheckInterval int32 `db:"healthcheck_interval" json:"healthcheck_interval"`
|
||||
HealthcheckThreshold int32 `db:"healthcheck_threshold" json:"healthcheck_threshold"`
|
||||
Health WorkspaceAppHealth `db:"health" json:"health"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) {
|
||||
@ -4018,6 +4042,10 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
|
||||
arg.Command,
|
||||
arg.Url,
|
||||
arg.RelativePath,
|
||||
arg.HealthcheckUrl,
|
||||
arg.HealthcheckInterval,
|
||||
arg.HealthcheckThreshold,
|
||||
arg.Health,
|
||||
)
|
||||
var i WorkspaceApp
|
||||
err := row.Scan(
|
||||
@ -4029,10 +4057,33 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
|
||||
&i.Command,
|
||||
&i.Url,
|
||||
&i.RelativePath,
|
||||
&i.HealthcheckUrl,
|
||||
&i.HealthcheckInterval,
|
||||
&i.HealthcheckThreshold,
|
||||
&i.Health,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec
|
||||
UPDATE
|
||||
workspace_apps
|
||||
SET
|
||||
health = $2
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
||||
type UpdateWorkspaceAppHealthByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Health WorkspaceAppHealth `db:"health" json:"health"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceAppHealthByID, arg.ID, arg.Health)
|
||||
return err
|
||||
}
|
||||
|
||||
const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason
|
||||
|
@ -20,7 +20,19 @@ INSERT INTO
|
||||
icon,
|
||||
command,
|
||||
url,
|
||||
relative_path
|
||||
relative_path,
|
||||
healthcheck_url,
|
||||
healthcheck_interval,
|
||||
healthcheck_threshold,
|
||||
health
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspaceAppHealthByID :exec
|
||||
UPDATE
|
||||
workspace_apps
|
||||
SET
|
||||
health = $2
|
||||
WHERE
|
||||
id = $1;
|
||||
|
@ -812,6 +812,14 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, telemetry.ConvertWorkspaceAgent(dbAgent))
|
||||
|
||||
for _, app := range prAgent.Apps {
|
||||
health := database.WorkspaceAppHealthDisabled
|
||||
if app.Healthcheck == nil {
|
||||
app.Healthcheck = &sdkproto.Healthcheck{}
|
||||
}
|
||||
if app.Healthcheck.Url != "" {
|
||||
health = database.WorkspaceAppHealthInitializing
|
||||
}
|
||||
|
||||
dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: database.Now(),
|
||||
@ -826,7 +834,11 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
String: app.Url,
|
||||
Valid: app.Url != "",
|
||||
},
|
||||
RelativePath: app.RelativePath,
|
||||
RelativePath: app.RelativePath,
|
||||
HealthcheckUrl: app.Healthcheck.Url,
|
||||
HealthcheckInterval: app.Healthcheck.Interval,
|
||||
HealthcheckThreshold: app.Healthcheck.Threshold,
|
||||
Health: health,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert app: %w", err)
|
||||
|
@ -23,7 +23,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
@ -61,6 +60,20 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, apiAgent)
|
||||
}
|
||||
|
||||
func (api *API) workspaceAgentApps(rw http.ResponseWriter, r *http.Request) {
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
dbApps, err := api.Database.GetWorkspaceAppsByAgentID(r.Context(), workspaceAgent.ID)
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace agent applications.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, convertApps(dbApps))
|
||||
}
|
||||
|
||||
func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
@ -73,7 +86,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, agent.Metadata{
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentMetadata{
|
||||
DERPMap: api.DERPMap,
|
||||
EnvironmentVariables: apiAgent.EnvironmentVariables,
|
||||
StartupScript: apiAgent.StartupScript,
|
||||
@ -205,7 +218,7 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(ptNetConn, wsNetConn)
|
||||
}
|
||||
|
||||
func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (*agent.Conn, error) {
|
||||
func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (*codersdk.AgentConn, error) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
go func() {
|
||||
<-r.Context().Done()
|
||||
@ -232,7 +245,7 @@ func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (*
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
return &agent.Conn{
|
||||
return &codersdk.AgentConn{
|
||||
Conn: conn,
|
||||
}, nil
|
||||
}
|
||||
@ -442,6 +455,12 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
|
||||
Name: dbApp.Name,
|
||||
Command: dbApp.Command.String,
|
||||
Icon: dbApp.Icon,
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
URL: dbApp.HealthcheckUrl,
|
||||
Interval: dbApp.HealthcheckInterval,
|
||||
Threshold: dbApp.HealthcheckThreshold,
|
||||
},
|
||||
Health: codersdk.WorkspaceAppHealth(dbApp.Health),
|
||||
})
|
||||
}
|
||||
return apps
|
||||
@ -667,6 +686,94 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request) {
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
var req codersdk.PostWorkspaceAppHealthsRequest
|
||||
if !httpapi.Read(r.Context(), rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Healths == nil || len(req.Healths) == 0 {
|
||||
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Health field is empty",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
apps, err := api.Database.GetWorkspaceAppsByAgentID(r.Context(), workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Error getting agent apps",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var newApps []database.WorkspaceApp
|
||||
for name, newHealth := range req.Healths {
|
||||
old := func() *database.WorkspaceApp {
|
||||
for _, app := range apps {
|
||||
if app.Name == name {
|
||||
return &app
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if old == nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Error setting workspace app health",
|
||||
Detail: xerrors.Errorf("workspace app name %s not found", name).Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if old.HealthcheckUrl == "" {
|
||||
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Error setting workspace app health",
|
||||
Detail: xerrors.Errorf("health checking is disabled for workspace app %s", name).Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch newHealth {
|
||||
case codersdk.WorkspaceAppHealthInitializing:
|
||||
case codersdk.WorkspaceAppHealthHealthy:
|
||||
case codersdk.WorkspaceAppHealthUnhealthy:
|
||||
default:
|
||||
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Error setting workspace app health",
|
||||
Detail: xerrors.Errorf("workspace app health %s is not a valid value", newHealth).Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// don't save if the value hasn't changed
|
||||
if old.Health == database.WorkspaceAppHealth(newHealth) {
|
||||
continue
|
||||
}
|
||||
old.Health = database.WorkspaceAppHealth(newHealth)
|
||||
|
||||
newApps = append(newApps, *old)
|
||||
}
|
||||
|
||||
for _, app := range newApps {
|
||||
err = api.Database.UpdateWorkspaceAppHealthByID(r.Context(), database.UpdateWorkspaceAppHealthByIDParams{
|
||||
ID: app.ID,
|
||||
Health: app.Health,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Error setting workspace app health",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
|
||||
// is called if a read or write error is encountered.
|
||||
type wsNetConn struct {
|
||||
|
@ -324,7 +324,7 @@ func TestWorkspaceAgentPTY(t *testing.T) {
|
||||
|
||||
// First attempt to resize the TTY.
|
||||
// The websocket will close if it fails!
|
||||
data, err := json.Marshal(agent.ReconnectingPTYRequest{
|
||||
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Height: 250,
|
||||
Width: 250,
|
||||
})
|
||||
@ -337,7 +337,7 @@ func TestWorkspaceAgentPTY(t *testing.T) {
|
||||
// the shell is simultaneously sending a prompt.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
data, err = json.Marshal(agent.ReconnectingPTYRequest{
|
||||
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
|
||||
Data: "echo test\r\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@ -363,3 +363,112 @@ func TestWorkspaceAgentPTY(t *testing.T) {
|
||||
expectLine(matchEchoCommand)
|
||||
expectLine(matchEchoOutput)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentAppHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
apps := []*proto.App{
|
||||
{
|
||||
Name: "code-server",
|
||||
Command: "some-command",
|
||||
Url: "http://localhost:3000",
|
||||
Icon: "/code.svg",
|
||||
},
|
||||
{
|
||||
Name: "code-server-2",
|
||||
Command: "some-command",
|
||||
Url: "http://localhost:3000",
|
||||
Icon: "/code.svg",
|
||||
Healthcheck: &proto.Healthcheck{
|
||||
Url: "http://localhost:3000",
|
||||
Interval: 5,
|
||||
Threshold: 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
Apps: apps,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
|
||||
apiApps, err := agentClient.WorkspaceAgentApps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apiApps[0].Health)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, apiApps[1].Health)
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{})
|
||||
require.Error(t, err)
|
||||
// empty
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{})
|
||||
require.Error(t, err)
|
||||
// invalid name
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
Healths: map[string]codersdk.WorkspaceAppHealth{
|
||||
"bad-name": codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// healcheck disabled
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
Healths: map[string]codersdk.WorkspaceAppHealth{
|
||||
"code-server": codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// invalid value
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
Healths: map[string]codersdk.WorkspaceAppHealth{
|
||||
"code-server-2": codersdk.WorkspaceAppHealth("bad-value"),
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// update to healthy
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
Healths: map[string]codersdk.WorkspaceAppHealth{
|
||||
"code-server-2": codersdk.WorkspaceAppHealthHealthy,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
apiApps, err = agentClient.WorkspaceAgentApps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthHealthy, apiApps[1].Health)
|
||||
// update to unhealthy
|
||||
err = agentClient.PostWorkspaceAgentAppHealth(ctx, codersdk.PostWorkspaceAppHealthsRequest{
|
||||
Healths: map[string]codersdk.WorkspaceAppHealth{
|
||||
"code-server-2": codersdk.WorkspaceAppHealthUnhealthy,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
apiApps, err = agentClient.WorkspaceAgentApps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, apiApps[1].Health)
|
||||
}
|
||||
|
@ -74,11 +74,24 @@ func TestWorkspaceResource(t *testing.T) {
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
app := &proto.App{
|
||||
Name: "code-server",
|
||||
Command: "some-command",
|
||||
Url: "http://localhost:3000",
|
||||
Icon: "/code.svg",
|
||||
apps := []*proto.App{
|
||||
{
|
||||
Name: "code-server",
|
||||
Command: "some-command",
|
||||
Url: "http://localhost:3000",
|
||||
Icon: "/code.svg",
|
||||
},
|
||||
{
|
||||
Name: "code-server-2",
|
||||
Command: "some-command",
|
||||
Url: "http://localhost:3000",
|
||||
Icon: "/code.svg",
|
||||
Healthcheck: &proto.Healthcheck{
|
||||
Url: "http://localhost:3000",
|
||||
Interval: 5,
|
||||
Threshold: 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
@ -91,7 +104,7 @@ func TestWorkspaceResource(t *testing.T) {
|
||||
Agents: []*proto.Agent{{
|
||||
Id: "something",
|
||||
Auth: &proto.Agent_Token{},
|
||||
Apps: []*proto.App{app},
|
||||
Apps: apps,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
@ -112,11 +125,25 @@ func TestWorkspaceResource(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resource.Agents, 1)
|
||||
agent := resource.Agents[0]
|
||||
require.Len(t, agent.Apps, 1)
|
||||
require.Len(t, agent.Apps, 2)
|
||||
got := agent.Apps[0]
|
||||
require.Equal(t, app.Command, got.Command)
|
||||
require.Equal(t, app.Icon, got.Icon)
|
||||
require.Equal(t, app.Name, got.Name)
|
||||
app := apps[0]
|
||||
require.EqualValues(t, app.Command, got.Command)
|
||||
require.EqualValues(t, app.Icon, got.Icon)
|
||||
require.EqualValues(t, app.Name, got.Name)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, got.Health)
|
||||
require.EqualValues(t, "", got.Healthcheck.URL)
|
||||
require.EqualValues(t, 0, got.Healthcheck.Interval)
|
||||
require.EqualValues(t, 0, got.Healthcheck.Threshold)
|
||||
got = agent.Apps[1]
|
||||
app = apps[1]
|
||||
require.EqualValues(t, app.Command, got.Command)
|
||||
require.EqualValues(t, app.Icon, got.Icon)
|
||||
require.EqualValues(t, app.Name, got.Name)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, got.Health)
|
||||
require.EqualValues(t, app.Healthcheck.Url, got.Healthcheck.URL)
|
||||
require.EqualValues(t, app.Healthcheck.Interval, got.Healthcheck.Interval)
|
||||
require.EqualValues(t, app.Healthcheck.Threshold, got.Healthcheck.Threshold)
|
||||
})
|
||||
|
||||
t.Run("Metadata", func(t *testing.T) {
|
||||
|
@ -12,7 +12,7 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// New creates a new workspace connection cache that closes
|
||||
@ -32,11 +32,11 @@ func New(dialer Dialer, inactiveTimeout time.Duration) *Cache {
|
||||
}
|
||||
|
||||
// Dialer creates a new agent connection by ID.
|
||||
type Dialer func(r *http.Request, id uuid.UUID) (*agent.Conn, error)
|
||||
type Dialer func(r *http.Request, id uuid.UUID) (*codersdk.AgentConn, error)
|
||||
|
||||
// Conn wraps an agent connection with a reusable HTTP transport.
|
||||
type Conn struct {
|
||||
*agent.Conn
|
||||
*codersdk.AgentConn
|
||||
|
||||
locks atomic.Uint64
|
||||
timeoutMutex sync.Mutex
|
||||
@ -59,7 +59,7 @@ func (c *Conn) CloseWithError(err error) error {
|
||||
if c.timeout != nil {
|
||||
c.timeout.Stop()
|
||||
}
|
||||
return c.Conn.CloseWithError(err)
|
||||
return c.AgentConn.CloseWithError(err)
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
@ -98,7 +98,7 @@ func (c *Cache) Acquire(r *http.Request, id uuid.UUID) (*Conn, func(), error) {
|
||||
transport := defaultTransport.Clone()
|
||||
transport.DialContext = agentConn.DialContext
|
||||
conn := &Conn{
|
||||
Conn: agentConn,
|
||||
AgentConn: agentConn,
|
||||
timeoutCancel: timeoutCancelFunc,
|
||||
transport: transport,
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/wsconncache"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/tailnet"
|
||||
"github.com/coder/coder/tailnet/tailnettest"
|
||||
)
|
||||
@ -35,8 +36,8 @@ func TestCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Same", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*agent.Conn, error) {
|
||||
return setupAgent(t, agent.Metadata{}, 0), nil
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*codersdk.AgentConn, error) {
|
||||
return setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0), nil
|
||||
}, 0)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -50,9 +51,9 @@ func TestCache(t *testing.T) {
|
||||
t.Run("Expire", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := atomic.NewInt32(0)
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*agent.Conn, error) {
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*codersdk.AgentConn, error) {
|
||||
called.Add(1)
|
||||
return setupAgent(t, agent.Metadata{}, 0), nil
|
||||
return setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0), nil
|
||||
}, time.Microsecond)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -69,8 +70,8 @@ func TestCache(t *testing.T) {
|
||||
})
|
||||
t.Run("NoExpireWhenLocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*agent.Conn, error) {
|
||||
return setupAgent(t, agent.Metadata{}, 0), nil
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*codersdk.AgentConn, error) {
|
||||
return setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0), nil
|
||||
}, time.Microsecond)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -102,8 +103,8 @@ func TestCache(t *testing.T) {
|
||||
}()
|
||||
go server.Serve(random)
|
||||
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*agent.Conn, error) {
|
||||
return setupAgent(t, agent.Metadata{}, 0), nil
|
||||
cache := wsconncache.New(func(r *http.Request, id uuid.UUID) (*codersdk.AgentConn, error) {
|
||||
return setupAgent(t, codersdk.WorkspaceAgentMetadata{}, 0), nil
|
||||
}, time.Microsecond)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -139,13 +140,13 @@ func TestCache(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn {
|
||||
func setupAgent(t *testing.T, metadata codersdk.WorkspaceAgentMetadata, ptyTimeout time.Duration) *codersdk.AgentConn {
|
||||
metadata.DERPMap = tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
coordinator := tailnet.NewCoordinator()
|
||||
agentID := uuid.New()
|
||||
closer := agent.New(agent.Options{
|
||||
FetchMetadata: func(ctx context.Context) (agent.Metadata, error) {
|
||||
FetchMetadata: func(ctx context.Context) (codersdk.WorkspaceAgentMetadata, error) {
|
||||
return metadata, nil
|
||||
},
|
||||
CoordinatorDialer: func(ctx context.Context) (net.Conn, error) {
|
||||
@ -180,7 +181,7 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration)
|
||||
return conn.UpdateNodes(node)
|
||||
})
|
||||
conn.SetNodeCallback(sendNode)
|
||||
return &agent.Conn{
|
||||
return &codersdk.AgentConn{
|
||||
Conn: conn,
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user