feat: add health check monitoring to workspace apps (#4114)

This commit is contained in:
Garrett Delfosse
2022-09-23 15:51:04 -04:00
committed by GitHub
parent f160830226
commit 4c8be34d81
64 changed files with 1592 additions and 509 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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