mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add healthy filter for workspace queries (#21743)
Adds support for filtering workspaces by health status using healthy:true or healthy:false in the search query. This is done by changing `has-agent` to accept a list of statuses and aliasing `health:true` to `has-agent:connected` and `healthy:false` to `has-agent:timeout,disconnected`. Fixes #21623
This commit is contained in:
Generated
+1
-1
@@ -10927,7 +10927,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent.",
|
||||
"description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy.",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
|
||||
Generated
+1
-1
@@ -9665,7 +9665,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent.",
|
||||
"description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy.",
|
||||
"name": "q",
|
||||
"in": "query"
|
||||
},
|
||||
|
||||
@@ -268,7 +268,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
pq.Array(arg.TemplateIDs),
|
||||
pq.Array(arg.WorkspaceIds),
|
||||
arg.Name,
|
||||
arg.HasAgent,
|
||||
pq.Array(arg.HasAgentStatuses),
|
||||
arg.AgentInactiveDisconnectTimeoutSeconds,
|
||||
arg.Dormant,
|
||||
arg.LastUsedBefore,
|
||||
|
||||
@@ -22977,7 +22977,7 @@ WHERE
|
||||
-- Filter by agent status
|
||||
-- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents.
|
||||
AND CASE
|
||||
WHEN $13 :: text != '' THEN
|
||||
WHEN array_length($13 :: text[], 1) > 0 THEN
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
@@ -22991,7 +22991,7 @@ WHERE
|
||||
latest_build.transition = 'start'::workspace_transition AND
|
||||
-- Filter out deleted sub agents.
|
||||
workspace_agents.deleted = FALSE AND
|
||||
$13 = (
|
||||
(
|
||||
CASE
|
||||
WHEN workspace_agents.first_connected_at IS NULL THEN
|
||||
CASE
|
||||
@@ -23009,7 +23009,7 @@ WHERE
|
||||
ELSE
|
||||
NULL
|
||||
END
|
||||
)
|
||||
) = ANY($13 :: text[])
|
||||
) > 0
|
||||
ELSE true
|
||||
END
|
||||
@@ -23074,6 +23074,7 @@ WHERE
|
||||
workspaces.group_acl ? ($23 :: uuid) :: text
|
||||
ELSE true
|
||||
END
|
||||
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
|
||||
-- @authorize_filter
|
||||
), filtered_workspaces_order AS (
|
||||
@@ -23177,7 +23178,7 @@ type GetWorkspacesParams struct {
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
WorkspaceIds []uuid.UUID `db:"workspace_ids" json:"workspace_ids"`
|
||||
Name string `db:"name" json:"name"`
|
||||
HasAgent string `db:"has_agent" json:"has_agent"`
|
||||
HasAgentStatuses []string `db:"has_agent_statuses" json:"has_agent_statuses"`
|
||||
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
|
||||
Dormant bool `db:"dormant" json:"dormant"`
|
||||
LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"`
|
||||
@@ -23255,7 +23256,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
|
||||
pq.Array(arg.TemplateIDs),
|
||||
pq.Array(arg.WorkspaceIds),
|
||||
arg.Name,
|
||||
arg.HasAgent,
|
||||
pq.Array(arg.HasAgentStatuses),
|
||||
arg.AgentInactiveDisconnectTimeoutSeconds,
|
||||
arg.Dormant,
|
||||
arg.LastUsedBefore,
|
||||
|
||||
@@ -292,7 +292,7 @@ WHERE
|
||||
-- Filter by agent status
|
||||
-- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents.
|
||||
AND CASE
|
||||
WHEN @has_agent :: text != '' THEN
|
||||
WHEN array_length(@has_agent_statuses :: text[], 1) > 0 THEN
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
@@ -306,7 +306,7 @@ WHERE
|
||||
latest_build.transition = 'start'::workspace_transition AND
|
||||
-- Filter out deleted sub agents.
|
||||
workspace_agents.deleted = FALSE AND
|
||||
@has_agent = (
|
||||
(
|
||||
CASE
|
||||
WHEN workspace_agents.first_connected_at IS NULL THEN
|
||||
CASE
|
||||
@@ -324,7 +324,7 @@ WHERE
|
||||
ELSE
|
||||
NULL
|
||||
END
|
||||
)
|
||||
) = ANY(@has_agent_statuses :: text[])
|
||||
) > 0
|
||||
ELSE true
|
||||
END
|
||||
@@ -389,6 +389,7 @@ WHERE
|
||||
workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text
|
||||
ELSE true
|
||||
END
|
||||
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
|
||||
-- @authorize_filter
|
||||
), filtered_workspaces_order AS (
|
||||
|
||||
@@ -254,7 +254,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
|
||||
filter.TemplateName = parser.String(values, "", "template")
|
||||
filter.Name = parser.String(values, "", "name")
|
||||
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
|
||||
filter.HasAgent = parser.String(values, "", "has-agent")
|
||||
filter.HasAgentStatuses = parser.Strings(values, []string{}, "has-agent")
|
||||
filter.Dormant = parser.Boolean(values, false, "dormant")
|
||||
filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
|
||||
filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before")
|
||||
@@ -273,6 +273,15 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder
|
||||
// TODO: support "me" by passing in the actorID
|
||||
filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", uuid.Nil)
|
||||
filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
|
||||
// Translate healthy filter to has-agent statuses
|
||||
// healthy:true = connected, healthy:false = disconnected or timeout
|
||||
if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid {
|
||||
if healthy.Bool {
|
||||
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected")
|
||||
} else {
|
||||
filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
type paramMatch struct {
|
||||
name string
|
||||
|
||||
@@ -312,6 +312,34 @@ func TestSearchWorkspace(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "HealthyTrue",
|
||||
Query: "healthy:true",
|
||||
Expected: database.GetWorkspacesParams{
|
||||
HasAgentStatuses: []string{"connected"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "HealthyFalse",
|
||||
Query: "healthy:false",
|
||||
Expected: database.GetWorkspacesParams{
|
||||
HasAgentStatuses: []string{"disconnected", "timeout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "HealthyMissing",
|
||||
Query: "",
|
||||
Expected: database.GetWorkspacesParams{
|
||||
HasAgentStatuses: []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "HealthyAndHasAgent",
|
||||
Query: "has-agent:connecting healthy:true",
|
||||
Expected: database.GetWorkspacesParams{
|
||||
HasAgentStatuses: []string{"connecting", "connected"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SharedWithUser",
|
||||
Query: `shared_with_user:3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf`,
|
||||
@@ -474,6 +502,10 @@ func TestSearchWorkspace(t *testing.T) {
|
||||
// nil slice vs 0 len slice is equivalent for our purposes.
|
||||
c.Expected.HasParam = values.HasParam
|
||||
}
|
||||
if len(c.Expected.HasAgentStatuses) == len(values.HasAgentStatuses) {
|
||||
// nil slice vs 0 len slice is equivalent for our purposes.
|
||||
c.Expected.HasAgentStatuses = values.HasAgentStatuses
|
||||
}
|
||||
assert.Len(t, errs, 0, "expected no error")
|
||||
assert.Equal(t, c.Expected, values, "expected values")
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Workspaces
|
||||
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent."
|
||||
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy."
|
||||
// @Param limit query int false "Page limit"
|
||||
// @Param offset query int false "Page offset"
|
||||
// @Success 200 {object} codersdk.WorkspacesResponse
|
||||
|
||||
@@ -2519,6 +2519,152 @@ func TestWorkspaceFilterManual(t *testing.T) {
|
||||
require.Len(t, res.Workspaces, 1)
|
||||
require.Equal(t, workspace.ID, res.Workspaces[0].ID)
|
||||
})
|
||||
|
||||
t.Run("HealthyFilter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Healthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// healthy:true should return workspaces with connected agents
|
||||
// and exclude workspaces with disconnected agents
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a workspace with a connected agent
|
||||
connectedBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "connected-workspace",
|
||||
}).WithAgent().Do()
|
||||
|
||||
// Mark the agent as connected
|
||||
now := time.Now()
|
||||
require.Len(t, connectedBuild.Agents, 1)
|
||||
//nolint:gocritic // This is a test, we need system context to update agent connection
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background())
|
||||
err := db.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
|
||||
ID: connectedBuild.Agents[0].ID,
|
||||
FirstConnectedAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastConnectedAt: sql.NullTime{Time: now, Valid: true},
|
||||
DisconnectedAt: sql.NullTime{},
|
||||
UpdatedAt: now,
|
||||
LastConnectedReplicaID: uuid.NullUUID{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a workspace with a disconnected agent
|
||||
disconnectedBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "disconnected-workspace",
|
||||
}).WithAgent().Do()
|
||||
|
||||
// Mark the agent as disconnected
|
||||
require.Len(t, disconnectedBuild.Agents, 1)
|
||||
disconnectedTime := now.Add(-time.Hour)
|
||||
err = db.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
|
||||
ID: disconnectedBuild.Agents[0].ID,
|
||||
FirstConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
|
||||
LastConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
|
||||
DisconnectedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: now,
|
||||
LastConnectedReplicaID: uuid.NullUUID{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// healthy:true should only return the connected workspace
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
FilterQuery: "healthy:true",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Workspaces, 1)
|
||||
require.Equal(t, connectedBuild.Workspace.ID, res.Workspaces[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Unhealthy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// healthy:false should return workspaces with disconnected or timed out agents
|
||||
// and exclude workspaces with connected agents
|
||||
store, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Database: store,
|
||||
Pubsub: ps,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
now := time.Now()
|
||||
|
||||
//nolint:gocritic // This is a test, we need system context to update agent connection
|
||||
ctx := dbauthz.AsSystemRestricted(context.Background())
|
||||
|
||||
// Create a workspace with a connected agent (should be excluded)
|
||||
connectedBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "connected-workspace",
|
||||
}).WithAgent().Do()
|
||||
require.Len(t, connectedBuild.Agents, 1)
|
||||
err := store.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
|
||||
ID: connectedBuild.Agents[0].ID,
|
||||
FirstConnectedAt: sql.NullTime{Time: now, Valid: true},
|
||||
LastConnectedAt: sql.NullTime{Time: now, Valid: true},
|
||||
DisconnectedAt: sql.NullTime{},
|
||||
UpdatedAt: now,
|
||||
LastConnectedReplicaID: uuid.NullUUID{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a workspace with a disconnected agent
|
||||
disconnectedBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "disconnected-workspace",
|
||||
}).WithAgent().Do()
|
||||
require.Len(t, disconnectedBuild.Agents, 1)
|
||||
disconnectedTime := now.Add(-time.Hour)
|
||||
err = store.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{
|
||||
ID: disconnectedBuild.Agents[0].ID,
|
||||
FirstConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
|
||||
LastConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true},
|
||||
DisconnectedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: now,
|
||||
LastConnectedReplicaID: uuid.NullUUID{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a workspace with a timed out agent (never connected, timeout exceeded)
|
||||
timedOutBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "timeout-workspace",
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].ConnectionTimeoutSeconds = 1
|
||||
return agents
|
||||
}).Do()
|
||||
require.Len(t, timedOutBuild.Agents, 1)
|
||||
// Set created_at to the past so the timeout is exceeded
|
||||
_, err = sqlDB.ExecContext(ctx, "UPDATE workspace_agents SET created_at = $1 WHERE id = $2",
|
||||
now.Add(-time.Hour), timedOutBuild.Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// healthy:false should return both disconnected and timed out workspaces
|
||||
res, err := client.Workspaces(testCtx, codersdk.WorkspaceFilter{
|
||||
FilterQuery: "healthy:false",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Workspaces, 2)
|
||||
workspaceIDs := []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID}
|
||||
require.Contains(t, workspaceIDs, disconnectedBuild.Workspace.ID)
|
||||
require.Contains(t, workspaceIDs, timedOutBuild.Workspace.ID)
|
||||
})
|
||||
})
|
||||
t.Run("Params", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user