chore: accept payload on workspace usage route (#13544)

This commit is contained in:
Garrett Delfosse
2024-06-14 10:08:45 -04:00
committed by GitHub
parent 87820a29d7
commit 44d69139d5
9 changed files with 452 additions and 15 deletions

47
coderd/apidoc/docs.go generated
View File

@ -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": [

View File

@ -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"],

View File

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

View File

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