feat(coderd): add support for sending batched agent metadata (#10223)

Part of #9782
This commit is contained in:
Mathias Fredriksson 2023-10-13 16:37:55 +03:00 committed by GitHub
parent 1b1ab97c24
commit 7eeba15d16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 472 additions and 146 deletions

View File

@ -90,7 +90,7 @@ type Client interface {
PostLifecycle(ctx context.Context, state agentsdk.PostLifecycleRequest) error
PostAppHealth(ctx context.Context, req agentsdk.PostAppHealthsRequest) error
PostStartup(ctx context.Context, req agentsdk.PostStartupRequest) error
PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error
PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequestDeprecated) error
PatchLogs(ctx context.Context, req agentsdk.PatchLogs) error
GetServiceBanner(ctx context.Context) (codersdk.ServiceBannerConfig, error)
}

View File

@ -1079,7 +1079,7 @@ func TestAgent_Metadata(t *testing.T) {
opts.ReportMetadataInterval = 100 * time.Millisecond
})
var gotMd map[string]agentsdk.PostMetadataRequest
var gotMd map[string]agentsdk.PostMetadataRequestDeprecated
require.Eventually(t, func() bool {
gotMd = client.GetMetadata()
return len(gotMd) == 1
@ -1112,7 +1112,7 @@ func TestAgent_Metadata(t *testing.T) {
opts.ReportMetadataInterval = testutil.IntervalFast
})
var gotMd map[string]agentsdk.PostMetadataRequest
var gotMd map[string]agentsdk.PostMetadataRequestDeprecated
require.Eventually(t, func() bool {
gotMd = client.GetMetadata()
return len(gotMd) == 1

View File

@ -45,7 +45,7 @@ type Client struct {
logger slog.Logger
agentID uuid.UUID
manifest agentsdk.Manifest
metadata map[string]agentsdk.PostMetadataRequest
metadata map[string]agentsdk.PostMetadataRequestDeprecated
statsChan chan *agentsdk.Stats
coordinator tailnet.Coordinator
LastWorkspaceAgent func()
@ -136,17 +136,17 @@ func (c *Client) GetStartup() agentsdk.PostStartupRequest {
return c.startup
}
func (c *Client) GetMetadata() map[string]agentsdk.PostMetadataRequest {
func (c *Client) GetMetadata() map[string]agentsdk.PostMetadataRequestDeprecated {
c.mu.Lock()
defer c.mu.Unlock()
return maps.Clone(c.metadata)
}
func (c *Client) PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequest) error {
func (c *Client) PostMetadata(ctx context.Context, key string, req agentsdk.PostMetadataRequestDeprecated) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.metadata == nil {
c.metadata = make(map[string]agentsdk.PostMetadataRequest)
c.metadata = make(map[string]agentsdk.PostMetadataRequestDeprecated)
}
c.metadata[key] = req
c.logger.Debug(ctx, "post metadata", slog.F("key", key), slog.F("req", req))

76
coderd/apidoc/docs.go generated
View File

@ -4867,7 +4867,7 @@ const docTemplate = `{
}
}
},
"/workspaceagents/me/metadata/{key}": {
"/workspaceagents/me/metadata": {
"post": {
"security": [
{
@ -4889,7 +4889,46 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agentsdk.PostMetadataRequest"
"type": "array",
"items": {
"$ref": "#/definitions/agentsdk.PostMetadataRequest"
}
}
}
],
"responses": {
"204": {
"description": "Success"
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/workspaceagents/me/metadata/{key}": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"tags": [
"Agents"
],
"summary": "Removed: Submit workspace agent metadata",
"operationId": "removed-submit-workspace-agent-metadata",
"parameters": [
{
"description": "Workspace agent metadata request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agentsdk.PostMetadataRequestDeprecated"
}
},
{
@ -6708,6 +6747,28 @@ const docTemplate = `{
}
}
},
"agentsdk.Metadata": {
"type": "object",
"properties": {
"age": {
"description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.",
"type": "integer"
},
"collected_at": {
"type": "string",
"format": "date-time"
},
"error": {
"type": "string"
},
"key": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"agentsdk.PatchLogs": {
"type": "object",
"properties": {
@ -6746,6 +6807,17 @@ const docTemplate = `{
}
},
"agentsdk.PostMetadataRequest": {
"type": "object",
"properties": {
"metadata": {
"type": "array",
"items": {
"$ref": "#/definitions/agentsdk.Metadata"
}
}
}
},
"agentsdk.PostMetadataRequestDeprecated": {
"type": "object",
"properties": {
"age": {

View File

@ -4281,7 +4281,7 @@
}
}
},
"/workspaceagents/me/metadata/{key}": {
"/workspaceagents/me/metadata": {
"post": {
"security": [
{
@ -4299,7 +4299,42 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agentsdk.PostMetadataRequest"
"type": "array",
"items": {
"$ref": "#/definitions/agentsdk.PostMetadataRequest"
}
}
}
],
"responses": {
"204": {
"description": "Success"
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/workspaceagents/me/metadata/{key}": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"tags": ["Agents"],
"summary": "Removed: Submit workspace agent metadata",
"operationId": "removed-submit-workspace-agent-metadata",
"parameters": [
{
"description": "Workspace agent metadata request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agentsdk.PostMetadataRequestDeprecated"
}
},
{
@ -5922,6 +5957,28 @@
}
}
},
"agentsdk.Metadata": {
"type": "object",
"properties": {
"age": {
"description": "Age is the number of seconds since the metadata was collected.\nIt is provided in addition to CollectedAt to protect against clock skew.",
"type": "integer"
},
"collected_at": {
"type": "string",
"format": "date-time"
},
"error": {
"type": "string"
},
"key": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"agentsdk.PatchLogs": {
"type": "object",
"properties": {
@ -5960,6 +6017,17 @@
}
},
"agentsdk.PostMetadataRequest": {
"type": "object",
"properties": {
"metadata": {
"type": "array",
"items": {
"$ref": "#/definitions/agentsdk.Metadata"
}
}
}
},
"agentsdk.PostMetadataRequestDeprecated": {
"type": "object",
"properties": {
"age": {

View File

@ -821,7 +821,8 @@ func New(options *Options) *API {
r.Get("/coordinate", api.workspaceAgentCoordinate)
r.Post("/report-stats", api.workspaceAgentReportStats)
r.Post("/report-lifecycle", api.workspaceAgentReportLifecycle)
r.Post("/metadata/{key}", api.workspaceAgentPostMetadata)
r.Post("/metadata", api.workspaceAgentPostMetadata)
r.Post("/metadata/{key}", api.workspaceAgentPostMetadataDeprecated)
})
r.Route("/{workspaceagent}", func(r chi.Router) {
r.Use(

View File

@ -1643,8 +1643,8 @@ func (q *querier) GetWorkspaceAgentLogsAfter(ctx context.Context, arg database.G
return q.db.GetWorkspaceAgentLogsAfter(ctx, arg)
}
func (q *querier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) {
workspace, err := q.db.GetWorkspaceByAgentID(ctx, workspaceAgentID)
func (q *querier) GetWorkspaceAgentMetadata(ctx context.Context, arg database.GetWorkspaceAgentMetadataParams) ([]database.WorkspaceAgentMetadatum, error) {
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.WorkspaceAgentID)
if err != nil {
return nil, err
}
@ -1654,7 +1654,7 @@ func (q *querier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentI
return nil, err
}
return q.db.GetWorkspaceAgentMetadata(ctx, workspaceAgentID)
return q.db.GetWorkspaceAgentMetadata(ctx, arg)
}
func (q *querier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) {

View File

@ -3518,13 +3518,20 @@ func (q *FakeQuerier) GetWorkspaceAgentLogsAfter(_ context.Context, arg database
return logs, nil
}
func (q *FakeQuerier) GetWorkspaceAgentMetadata(_ context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) {
func (q *FakeQuerier) GetWorkspaceAgentMetadata(_ context.Context, arg database.GetWorkspaceAgentMetadataParams) ([]database.WorkspaceAgentMetadatum, error) {
if err := validateDatabaseType(arg); err != nil {
return nil, err
}
q.mutex.RLock()
defer q.mutex.RUnlock()
metadata := make([]database.WorkspaceAgentMetadatum, 0)
for _, m := range q.workspaceAgentMetadata {
if m.WorkspaceAgentID == workspaceAgentID {
if m.WorkspaceAgentID == arg.WorkspaceAgentID {
if len(arg.Keys) > 0 && !slices.Contains(arg.Keys, m.Key) {
continue
}
metadata = append(metadata, m)
}
}
@ -6133,19 +6140,17 @@ func (q *FakeQuerier) UpdateWorkspaceAgentMetadata(_ context.Context, arg databa
q.mutex.Lock()
defer q.mutex.Unlock()
//nolint:gosimple
updated := database.WorkspaceAgentMetadatum{
WorkspaceAgentID: arg.WorkspaceAgentID,
Key: arg.Key,
Value: arg.Value,
Error: arg.Error,
CollectedAt: arg.CollectedAt,
}
for i, m := range q.workspaceAgentMetadata {
if m.WorkspaceAgentID == arg.WorkspaceAgentID && m.Key == arg.Key {
q.workspaceAgentMetadata[i] = updated
return nil
if m.WorkspaceAgentID != arg.WorkspaceAgentID {
continue
}
for j := 0; j < len(arg.Key); j++ {
if m.Key == arg.Key[j] {
q.workspaceAgentMetadata[i].Value = arg.Value[j]
q.workspaceAgentMetadata[i].Error = arg.Error[j]
q.workspaceAgentMetadata[i].CollectedAt = arg.CollectedAt[j]
return nil
}
}
}

View File

@ -900,7 +900,7 @@ func (m metricsStore) GetWorkspaceAgentLogsAfter(ctx context.Context, arg databa
return r0, r1
}
func (m metricsStore) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) {
func (m metricsStore) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID database.GetWorkspaceAgentMetadataParams) ([]database.WorkspaceAgentMetadatum, error) {
start := time.Now()
metadata, err := m.s.GetWorkspaceAgentMetadata(ctx, workspaceAgentID)
m.queryLatencies.WithLabelValues("GetWorkspaceAgentMetadata").Observe(time.Since(start).Seconds())

View File

@ -1869,7 +1869,7 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAgentLogsAfter(arg0, arg1 interface
}
// GetWorkspaceAgentMetadata mocks base method.
func (m *MockStore) GetWorkspaceAgentMetadata(arg0 context.Context, arg1 uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) {
func (m *MockStore) GetWorkspaceAgentMetadata(arg0 context.Context, arg1 database.GetWorkspaceAgentMetadataParams) ([]database.WorkspaceAgentMetadatum, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspaceAgentMetadata", arg0, arg1)
ret0, _ := ret[0].([]database.WorkspaceAgentMetadatum)

View File

@ -192,7 +192,7 @@ type sqlcQuerier interface {
GetWorkspaceAgentLifecycleStateByID(ctx context.Context, id uuid.UUID) (GetWorkspaceAgentLifecycleStateByIDRow, error)
GetWorkspaceAgentLogSourcesByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentLogSource, error)
GetWorkspaceAgentLogsAfter(ctx context.Context, arg GetWorkspaceAgentLogsAfterParams) ([]WorkspaceAgentLog, error)
GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentMetadatum, error)
GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorkspaceAgentMetadataParams) ([]WorkspaceAgentMetadatum, error)
GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error)
GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error)
GetWorkspaceAgentStatsAndLabels(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsAndLabelsRow, error)

View File

@ -7318,10 +7318,16 @@ FROM
workspace_agent_metadata
WHERE
workspace_agent_id = $1
AND CASE WHEN COALESCE(array_length($2::text[], 1), 0) > 0 THEN key = ANY($2::text[]) ELSE TRUE END
`
func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentMetadatum, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceAgentMetadata, workspaceAgentID)
type GetWorkspaceAgentMetadataParams struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Keys []string `db:"keys" json:"keys"`
}
func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorkspaceAgentMetadataParams) ([]WorkspaceAgentMetadatum, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceAgentMetadata, arg.WorkspaceAgentID, pq.Array(arg.Keys))
if err != nil {
return nil, err
}
@ -7880,32 +7886,41 @@ func (q *sqlQuerier) UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, ar
}
const updateWorkspaceAgentMetadata = `-- name: UpdateWorkspaceAgentMetadata :exec
WITH metadata AS (
SELECT
unnest($2::text[]) AS key,
unnest($3::text[]) AS value,
unnest($4::text[]) AS error,
unnest($5::timestamptz[]) AS collected_at
)
UPDATE
workspace_agent_metadata
workspace_agent_metadata wam
SET
value = $3,
error = $4,
collected_at = $5
value = m.value,
error = m.error,
collected_at = m.collected_at
FROM
metadata m
WHERE
workspace_agent_id = $1
AND key = $2
wam.workspace_agent_id = $1
AND wam.key = m.key
`
type UpdateWorkspaceAgentMetadataParams struct {
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
Error string `db:"error" json:"error"`
CollectedAt time.Time `db:"collected_at" json:"collected_at"`
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Key []string `db:"key" json:"key"`
Value []string `db:"value" json:"value"`
Error []string `db:"error" json:"error"`
CollectedAt []time.Time `db:"collected_at" json:"collected_at"`
}
func (q *sqlQuerier) UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentMetadata,
arg.WorkspaceAgentID,
arg.Key,
arg.Value,
arg.Error,
arg.CollectedAt,
pq.Array(arg.Key),
pq.Array(arg.Value),
pq.Array(arg.Error),
pq.Array(arg.CollectedAt),
)
return err
}

View File

@ -108,15 +108,24 @@ VALUES
($1, $2, $3, $4, $5, $6);
-- name: UpdateWorkspaceAgentMetadata :exec
WITH metadata AS (
SELECT
unnest(sqlc.arg('key')::text[]) AS key,
unnest(sqlc.arg('value')::text[]) AS value,
unnest(sqlc.arg('error')::text[]) AS error,
unnest(sqlc.arg('collected_at')::timestamptz[]) AS collected_at
)
UPDATE
workspace_agent_metadata
workspace_agent_metadata wam
SET
value = $3,
error = $4,
collected_at = $5
value = m.value,
error = m.error,
collected_at = m.collected_at
FROM
metadata m
WHERE
workspace_agent_id = $1
AND key = $2;
wam.workspace_agent_id = $1
AND wam.key = m.key;
-- name: GetWorkspaceAgentMetadata :many
SELECT
@ -124,7 +133,8 @@ SELECT
FROM
workspace_agent_metadata
WHERE
workspace_agent_id = $1;
workspace_agent_id = $1
AND CASE WHEN COALESCE(array_length(sqlc.arg('keys')::text[], 1), 0) > 0 THEN key = ANY(sqlc.arg('keys')::text[]) ELSE TRUE END;
-- name: UpdateWorkspaceAgentLogOverflowByID :exec
UPDATE

View File

@ -3,7 +3,12 @@ package coderd
import (
"net/http"
"github.com/go-chi/chi/v5"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
// @Summary Removed: Get parameters by template version
@ -70,3 +75,42 @@ func (api *API) workspaceAgentLogsDeprecated(rw http.ResponseWriter, r *http.Req
func (api *API) workspaceAgentsGitAuth(rw http.ResponseWriter, r *http.Request) {
api.workspaceAgentsExternalAuth(rw, r)
}
// @Summary Removed: Submit workspace agent metadata
// @ID removed-submit-workspace-agent-metadata
// @Security CoderSessionToken
// @Accept json
// @Tags Agents
// @Param request body agentsdk.PostMetadataRequestDeprecated true "Workspace agent metadata request"
// @Param key path string true "metadata key" format(string)
// @Success 204 "Success"
// @Router /workspaceagents/me/metadata/{key} [post]
// @x-apidocgen {"skip": true}
func (api *API) workspaceAgentPostMetadataDeprecated(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req agentsdk.PostMetadataRequestDeprecated
if !httpapi.Read(ctx, rw, r, &req) {
return
}
workspaceAgent := httpmw.WorkspaceAgent(r)
key := chi.URLParam(r, "key")
err := api.workspaceAgentUpdateMetadata(ctx, workspaceAgent, agentsdk.PostMetadataRequest{
Metadata: []agentsdk.Metadata{
{
Key: key,
WorkspaceAgentMetadataResult: req,
},
},
})
if err != nil {
api.Logger.Error(ctx, "failed to handle metadata request", slog.Error(err))
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}

View File

@ -16,11 +16,9 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/exp/maps"
@ -181,7 +179,10 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request)
return err
})
eg.Go(func() (err error) {
metadata, err = api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID)
metadata, err = api.Database.GetWorkspaceAgentMetadata(ctx, database.GetWorkspaceAgentMetadataParams{
WorkspaceAgentID: workspaceAgent.ID,
Keys: nil,
})
return err
})
eg.Go(func() (err error) {
@ -1723,10 +1724,9 @@ func ellipse(v string, n int) string {
// @Security CoderSessionToken
// @Accept json
// @Tags Agents
// @Param request body agentsdk.PostMetadataRequest true "Workspace agent metadata request"
// @Param key path string true "metadata key" format(string)
// @Param request body []agentsdk.PostMetadataRequest true "Workspace agent metadata request"
// @Success 204 "Success"
// @Router /workspaceagents/me/metadata/{key} [post]
// @Router /workspaceagents/me/metadata [post]
// @x-apidocgen {"skip": true}
func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@ -1738,17 +1738,18 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque
workspaceAgent := httpmw.WorkspaceAgent(r)
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
// Split into function to allow call by deprecated handler.
err := api.workspaceAgentUpdateMetadata(ctx, workspaceAgent, req)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to get workspace.",
Detail: err.Error(),
})
api.Logger.Error(ctx, "failed to handle metadata request", slog.Error(err))
httpapi.InternalServerError(rw, err)
return
}
key := chi.URLParam(r, "key")
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
func (api *API) workspaceAgentUpdateMetadata(ctx context.Context, workspaceAgent database.WorkspaceAgent, req agentsdk.PostMetadataRequest) error {
const (
// maxValueLen is set to 2048 to stay under the 8000 byte Postgres
// NOTIFY limit. Since both value and error can be set, the real
@ -1758,58 +1759,67 @@ func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Reque
maxErrorLen = maxValueLen
)
metadataError := req.Error
// We overwrite the error if the provided payload is too long.
if len(req.Value) > maxValueLen {
metadataError = fmt.Sprintf("value of %d bytes exceeded %d bytes", len(req.Value), maxValueLen)
req.Value = req.Value[:maxValueLen]
}
if len(req.Error) > maxErrorLen {
metadataError = fmt.Sprintf("error of %d bytes exceeded %d bytes", len(req.Error), maxErrorLen)
req.Error = req.Error[:maxErrorLen]
}
collectedAt := time.Now()
datum := database.UpdateWorkspaceAgentMetadataParams{
WorkspaceAgentID: workspaceAgent.ID,
Key: []string{},
Value: []string{},
Error: []string{},
CollectedAt: []time.Time{},
}
for _, md := range req.Metadata {
metadataError := md.Error
// We overwrite the error if the provided payload is too long.
if len(md.Value) > maxValueLen {
metadataError = fmt.Sprintf("value of %d bytes exceeded %d bytes", len(md.Value), maxValueLen)
md.Value = md.Value[:maxValueLen]
}
if len(md.Error) > maxErrorLen {
metadataError = fmt.Sprintf("error of %d bytes exceeded %d bytes", len(md.Error), maxErrorLen)
md.Error = md.Error[:maxErrorLen]
}
// We don't want a misconfigured agent to fill the database.
Key: key,
Value: req.Value,
Error: metadataError,
datum.Key = append(datum.Key, md.Key)
datum.Value = append(datum.Value, md.Value)
datum.Error = append(datum.Error, metadataError)
// We ignore the CollectedAt from the agent to avoid bugs caused by
// clock skew.
CollectedAt: time.Now(),
datum.CollectedAt = append(datum.CollectedAt, collectedAt)
api.Logger.Debug(
ctx, "accepted metadata report",
slog.F("workspace_agent_id", workspaceAgent.ID),
slog.F("collected_at", collectedAt),
slog.F("original_collected_at", md.CollectedAt),
slog.F("key", md.Key),
slog.F("value", ellipse(md.Value, 16)),
)
}
payload, err := json.Marshal(workspaceAgentMetadataChannelPayload{
CollectedAt: collectedAt,
Keys: datum.Key,
})
if err != nil {
return err
}
err = api.Database.UpdateWorkspaceAgentMetadata(ctx, datum)
if err != nil {
httpapi.InternalServerError(rw, err)
return
return err
}
api.Logger.Debug(
ctx, "accepted metadata report",
slog.F("workspace_agent_id", workspaceAgent.ID),
slog.F("workspace_id", workspace.ID),
slog.F("collected_at", datum.CollectedAt),
slog.F("key", datum.Key),
slog.F("value", ellipse(datum.Value, 16)),
)
datumJSON, err := json.Marshal(datum)
err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), payload)
if err != nil {
httpapi.InternalServerError(rw, err)
return
return err
}
err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), datumJSON)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
return nil
}
// @Summary Watch for workspace agent metadata updates
@ -1829,34 +1839,37 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ
)
)
// We avoid channel-based synchronization here to avoid backpressure problems.
var (
metadataMapMu sync.Mutex
metadataMap = make(map[string]database.WorkspaceAgentMetadatum)
// pendingChanges must only be mutated when metadataMapMu is held.
pendingChanges atomic.Bool
)
// Send metadata on updates, we must ensure subscription before sending
// initial metadata to guarantee that events in-between are not missed.
update := make(chan workspaceAgentMetadataChannelPayload, 1)
cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, byt []byte) {
var update database.UpdateWorkspaceAgentMetadataParams
err := json.Unmarshal(byt, &update)
var payload workspaceAgentMetadataChannelPayload
err := json.Unmarshal(byt, &payload)
if err != nil {
api.Logger.Error(ctx, "failed to unmarshal pubsub message", slog.Error(err))
return
}
log.Debug(ctx, "received metadata update", "key", update.Key)
log.Debug(ctx, "received metadata update", "payload", payload)
metadataMapMu.Lock()
defer metadataMapMu.Unlock()
md := metadataMap[update.Key]
md.Value = update.Value
md.Error = update.Error
md.CollectedAt = update.CollectedAt
metadataMap[update.Key] = md
pendingChanges.Store(true)
select {
case prev := <-update:
// This update wasn't consumed yet, merge the keys.
newKeysSet := make(map[string]struct{})
for _, key := range payload.Keys {
newKeysSet[key] = struct{}{}
}
keys := prev.Keys
for _, key := range prev.Keys {
if _, ok := newKeysSet[key]; !ok {
keys = append(keys, key)
}
}
payload.Keys = keys
default:
}
// This can never block since we pop and merge beforehand.
update <- payload
})
if err != nil {
httpapi.InternalServerError(rw, err)
@ -1877,14 +1890,12 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ
<-sseSenderClosed
}()
// We send updates exactly every second.
const sendInterval = time.Second * 1
sendTicker := time.NewTicker(sendInterval)
defer sendTicker.Stop()
// We always use the original Request context because it contains
// the RBAC actor.
md, err := api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID)
md, err := api.Database.GetWorkspaceAgentMetadata(ctx, database.GetWorkspaceAgentMetadataParams{
WorkspaceAgentID: workspaceAgent.ID,
Keys: nil,
})
if err != nil {
// If we can't successfully pull the initial metadata, pubsub
// updates will be no-op so we may as well terminate the
@ -1893,42 +1904,84 @@ func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Requ
return
}
metadataMapMu.Lock()
metadataMap := make(map[string]database.WorkspaceAgentMetadatum)
for _, datum := range md {
metadataMap[datum.Key] = datum
}
metadataMapMu.Unlock()
// Send initial metadata.
var lastSend time.Time
sendMetadata := func() {
metadataMapMu.Lock()
values := maps.Values(metadataMap)
pendingChanges.Store(false)
metadataMapMu.Unlock()
lastSend = time.Now()
values := maps.Values(metadataMap)
_ = sseSendEvent(ctx, codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypeData,
Data: convertWorkspaceAgentMetadata(values),
})
}
// We send updates exactly every second.
const sendInterval = time.Second * 1
sendTicker := time.NewTicker(sendInterval)
defer sendTicker.Stop()
// Send initial metadata.
sendMetadata()
// Fetch updated metadata keys as they come in.
fetchedMetadata := make(chan []database.WorkspaceAgentMetadatum)
go func() {
defer close(fetchedMetadata)
for {
select {
case <-sseSenderClosed:
return
case payload := <-update:
md, err := api.Database.GetWorkspaceAgentMetadata(ctx, database.GetWorkspaceAgentMetadataParams{
WorkspaceAgentID: workspaceAgent.ID,
Keys: payload.Keys,
})
if err != nil {
if !errors.Is(err, context.Canceled) {
log.Error(ctx, "failed to get metadata", slog.Error(err))
}
return
}
select {
case <-sseSenderClosed:
return
// We want to block here to avoid constantly pinging the
// database when the metadata isn't being processed.
case fetchedMetadata <- md:
}
}
}
}()
pendingChanges := true
for {
select {
case <-sseSenderClosed:
return
case md, ok := <-fetchedMetadata:
if !ok {
return
}
for _, datum := range md {
metadataMap[datum.Key] = datum
}
pendingChanges = true
continue
case <-sendTicker.C:
// We send an update even if there's no change every 5 seconds
// to ensure that the frontend always has an accurate "Result.Age".
if !pendingChanges.Load() && time.Since(lastSend) < time.Second*5 {
if !pendingChanges && time.Since(lastSend) < 5*time.Second {
continue
}
sendMetadata()
case <-sseSenderClosed:
return
pendingChanges = false
}
sendMetadata()
}
}
@ -1959,6 +2012,11 @@ func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []code
return result
}
type workspaceAgentMetadataChannelPayload struct {
CollectedAt time.Time `json:"collected_at"`
Keys []string `json:"keys"`
}
func watchWorkspaceAgentMetadataChannel(id uuid.UUID) string {
return "workspace_agent_metadata:" + id.String()
}

View File

@ -267,7 +267,7 @@ func (*client) PostAppHealth(_ context.Context, _ agentsdk.PostAppHealthsRequest
return nil
}
func (*client) PostMetadata(_ context.Context, _ string, _ agentsdk.PostMetadataRequest) error {
func (*client) PostMetadata(_ context.Context, _ string, _ agentsdk.PostMetadataRequestDeprecated) error {
return nil
}

View File

@ -69,11 +69,20 @@ func (c *Client) GitSSHKey(ctx context.Context) (GitSSHKey, error) {
return gitSSHKey, json.NewDecoder(res.Body).Decode(&gitSSHKey)
}
type Metadata struct {
Key string `json:"key"`
codersdk.WorkspaceAgentMetadataResult
}
type PostMetadataRequest struct {
Metadata []Metadata `json:"metadata"`
}
// In the future, we may want to support sending back multiple values for
// performance.
type PostMetadataRequest = codersdk.WorkspaceAgentMetadataResult
type PostMetadataRequestDeprecated = codersdk.WorkspaceAgentMetadataResult
func (c *Client) PostMetadata(ctx context.Context, key string, req PostMetadataRequest) error {
func (c *Client) PostMetadata(ctx context.Context, key string, req PostMetadataRequestDeprecated) error {
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/metadata/"+key, req)
if err != nil {
return xerrors.Errorf("execute request: %w", err)

44
docs/api/schemas.md generated
View File

@ -317,6 +317,28 @@
| `scripts` | array of [codersdk.WorkspaceAgentScript](#codersdkworkspaceagentscript) | false | | |
| `vscode_port_proxy_uri` | string | false | | |
## agentsdk.Metadata
```json
{
"age": 0,
"collected_at": "2019-08-24T14:15:22Z",
"error": "string",
"key": "string",
"value": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `age` | integer | false | | Age is the number of seconds since the metadata was collected. It is provided in addition to CollectedAt to protect against clock skew. |
| `collected_at` | string | false | | |
| `error` | string | false | | |
| `key` | string | false | | |
| `value` | string | false | | |
## agentsdk.PatchLogs
```json
@ -375,6 +397,28 @@
## agentsdk.PostMetadataRequest
```json
{
"metadata": [
{
"age": 0,
"collected_at": "2019-08-24T14:15:22Z",
"error": "string",
"key": "string",
"value": "string"
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | ----------------------------------------------- | -------- | ------------ | ----------- |
| `metadata` | array of [agentsdk.Metadata](#agentsdkmetadata) | false | | |
## agentsdk.PostMetadataRequestDeprecated
```json
{
"age": 0,