mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
chore: accept payload on workspace usage route (#13544)
This commit is contained in:
47
coderd/apidoc/docs.go
generated
47
coderd/apidoc/docs.go
generated
@ -7527,6 +7527,9 @@ const docTemplate = `{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Workspaces"
|
||||
],
|
||||
@ -7540,6 +7543,14 @@ const docTemplate = `{
|
||||
"name": "workspace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Post workspace usage request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -9240,19 +9251,22 @@ const docTemplate = `{
|
||||
"example",
|
||||
"auto-fill-parameters",
|
||||
"multi-organization",
|
||||
"custom-roles"
|
||||
"custom-roles",
|
||||
"workspace-usage"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentCustomRoles": "Allows creating runtime custom roles",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed."
|
||||
"ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.",
|
||||
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking"
|
||||
},
|
||||
"x-enum-varnames": [
|
||||
"ExperimentExample",
|
||||
"ExperimentAutoFillParameters",
|
||||
"ExperimentMultiOrganization",
|
||||
"ExperimentCustomRoles"
|
||||
"ExperimentCustomRoles",
|
||||
"ExperimentWorkspaceUsage"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
@ -10223,6 +10237,18 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PostWorkspaceUsageRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"app_name": {
|
||||
"$ref": "#/definitions/codersdk.UsageAppName"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PprofConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -11978,6 +12004,21 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UsageAppName": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"vscode",
|
||||
"jetbrains",
|
||||
"reconnecting-pty",
|
||||
"ssh"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UsageAppNameVscode",
|
||||
"UsageAppNameJetbrains",
|
||||
"UsageAppNameReconnectingPty",
|
||||
"UsageAppNameSSH"
|
||||
]
|
||||
},
|
||||
"codersdk.User": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
40
coderd/apidoc/swagger.json
generated
40
coderd/apidoc/swagger.json
generated
@ -6661,6 +6661,7 @@
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Workspaces"],
|
||||
"summary": "Post Workspace Usage by ID",
|
||||
"operationId": "post-workspace-usage-by-id",
|
||||
@ -6672,6 +6673,14 @@
|
||||
"name": "workspace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Post workspace usage request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -8272,19 +8281,22 @@
|
||||
"example",
|
||||
"auto-fill-parameters",
|
||||
"multi-organization",
|
||||
"custom-roles"
|
||||
"custom-roles",
|
||||
"workspace-usage"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentCustomRoles": "Allows creating runtime custom roles",
|
||||
"ExperimentExample": "This isn't used for anything.",
|
||||
"ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed."
|
||||
"ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.",
|
||||
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking"
|
||||
},
|
||||
"x-enum-varnames": [
|
||||
"ExperimentExample",
|
||||
"ExperimentAutoFillParameters",
|
||||
"ExperimentMultiOrganization",
|
||||
"ExperimentCustomRoles"
|
||||
"ExperimentCustomRoles",
|
||||
"ExperimentWorkspaceUsage"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
@ -9200,6 +9212,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PostWorkspaceUsageRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"app_name": {
|
||||
"$ref": "#/definitions/codersdk.UsageAppName"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PprofConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -10863,6 +10887,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UsageAppName": {
|
||||
"type": "string",
|
||||
"enum": ["vscode", "jetbrains", "reconnecting-pty", "ssh"],
|
||||
"x-enum-varnames": [
|
||||
"UsageAppNameVscode",
|
||||
"UsageAppNameJetbrains",
|
||||
"UsageAppNameReconnectingPty",
|
||||
"UsageAppNameSSH"
|
||||
]
|
||||
},
|
||||
"codersdk.User": {
|
||||
"type": "object",
|
||||
"required": ["created_at", "email", "id", "username"],
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@ -15,6 +16,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
@ -1105,7 +1107,9 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||||
// @ID post-workspace-usage-by-id
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Workspaces
|
||||
// @Accept json
|
||||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||||
// @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request"
|
||||
// @Success 204
|
||||
// @Router /workspaces/{workspace}/usage [post]
|
||||
func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
|
||||
@ -1116,6 +1120,102 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
api.statsReporter.TrackUsage(workspace.ID)
|
||||
|
||||
if !api.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) {
|
||||
// Continue previous behavior if the experiment is not enabled.
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Body == http.NoBody {
|
||||
// Continue previous behavior if no body is present.
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
var req codersdk.PostWorkspaceUsageRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.AgentID == uuid.Nil && req.AppName == "" {
|
||||
// Continue previous behavior if body is empty.
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if req.AgentID == uuid.Nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid request",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "agent_id",
|
||||
Detail: "must be set when app_name is set",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.AppName == "" {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid request",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "app_name",
|
||||
Detail: "must be set when agent_id is set",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
if !slices.Contains(codersdk.AllowedAppNames, req.AppName) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid request",
|
||||
Validations: []codersdk.ValidationError{{
|
||||
Field: "app_name",
|
||||
Detail: fmt.Sprintf("must be one of %v", codersdk.AllowedAppNames),
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
stat := &proto.Stats{
|
||||
ConnectionCount: 1,
|
||||
}
|
||||
switch req.AppName {
|
||||
case codersdk.UsageAppNameVscode:
|
||||
stat.SessionCountVscode = 1
|
||||
case codersdk.UsageAppNameJetbrains:
|
||||
stat.SessionCountJetbrains = 1
|
||||
case codersdk.UsageAppNameReconnectingPty:
|
||||
stat.SessionCountReconnectingPty = 1
|
||||
case codersdk.UsageAppNameSSH:
|
||||
stat.SessionCountSsh = 1
|
||||
default:
|
||||
// This means the app_name is in the codersdk.AllowedAppNames but not being
|
||||
// handled by this switch statement.
|
||||
httpapi.InternalServerError(rw, xerrors.Errorf("unknown app_name %q", req.AppName))
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), workspace, agent, template.Name, stat)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
@ -3371,3 +3371,127 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) {
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
}
|
||||
|
||||
func TestWorkspaceUsageTracking(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("NoExperiment", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
tmpDir := t.TempDir()
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].Directory = tmpDir
|
||||
return agents
|
||||
}).Do()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
// continue legacy behavior
|
||||
err := client.PostWorkspaceUsage(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("Experiment", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)}
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
tmpDir := t.TempDir()
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
UserID: user.UserID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.UserID,
|
||||
})
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
ActiveVersionID: templateVersion.ID,
|
||||
CreatedBy: user.UserID,
|
||||
DefaultTTL: int64(8 * time.Hour),
|
||||
})
|
||||
_, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
ActivityBumpMillis: 8 * time.Hour.Milliseconds(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.Workspace{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
TemplateID: template.ID,
|
||||
Ttl: sql.NullInt64{Valid: true, Int64: int64(8 * time.Hour)},
|
||||
}).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].Directory = tmpDir
|
||||
return agents
|
||||
}).Do()
|
||||
|
||||
// continue legacy behavior
|
||||
err = client.PostWorkspaceUsage(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
workspace, err := client.Workspace(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// only agent id fails
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
|
||||
})
|
||||
require.ErrorContains(t, err, "agent_id")
|
||||
// only app name fails
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AppName: "ssh",
|
||||
})
|
||||
require.ErrorContains(t, err, "app_name")
|
||||
// unknown app name fails
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
|
||||
AppName: "unknown",
|
||||
})
|
||||
require.ErrorContains(t, err, "app_name")
|
||||
|
||||
// vscode works
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
|
||||
AppName: "vscode",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// jetbrains works
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
|
||||
AppName: "jetbrains",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// reconnecting-pty works
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
|
||||
AppName: "reconnecting-pty",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// ssh works
|
||||
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
|
||||
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
|
||||
AppName: "ssh",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ensure deadline has been bumped
|
||||
newWorkspace, err := client.Workspace(ctx, r.Workspace.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, workspace.LatestBuild.Deadline.Valid)
|
||||
require.True(t, newWorkspace.LatestBuild.Deadline.Valid)
|
||||
require.Greater(t, newWorkspace.LatestBuild.Deadline.Time, workspace.LatestBuild.Deadline.Time)
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user