mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
feat: add agent metadata (#6614)
This commit is contained in:
131
coderd/apidoc/docs.go
generated
131
coderd/apidoc/docs.go
generated
@ -4263,7 +4263,7 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/me/metadata": {
|
||||
"/workspaceagents/me/manifest": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
@ -4276,18 +4276,62 @@ const docTemplate = `{
|
||||
"tags": [
|
||||
"Agents"
|
||||
],
|
||||
"summary": "Get authorized workspace agent metadata",
|
||||
"operationId": "get-authorized-workspace-agent-metadata",
|
||||
"summary": "Get authorized workspace agent manifest",
|
||||
"operationId": "get-authorized-workspace-agent-manifest",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/agentsdk.Metadata"
|
||||
"$ref": "#/definitions/agentsdk.Manifest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/me/metadata/{key}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Agents"
|
||||
],
|
||||
"summary": "Submit workspace agent metadata",
|
||||
"operationId": "submit-workspace-agent-metadata",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Workspace agent metadata request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/agentsdk.PostMetadataRequest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "string",
|
||||
"description": "metadata key",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/me/report-lifecycle": {
|
||||
"post": {
|
||||
"security": [
|
||||
@ -4663,6 +4707,38 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/watch-metadata": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Agents"
|
||||
],
|
||||
"summary": "Watch for workspace agent metadata updates",
|
||||
"operationId": "watch-for-workspace-agent-metadata-updates",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace agent ID",
|
||||
"name": "workspaceagent",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspacebuilds/{workspacebuild}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -5397,7 +5473,7 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentsdk.Metadata": {
|
||||
"agentsdk.Manifest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apps": {
|
||||
@ -5422,6 +5498,12 @@ const docTemplate = `{
|
||||
"description": "GitAuthConfigs stores the number of Git configurations\nthe Coder deployment has. If this number is \u003e0, we\nset up special configuration in the workspace.",
|
||||
"type": "integer"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription"
|
||||
}
|
||||
},
|
||||
"motd_file": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -5473,6 +5555,25 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentsdk.PostMetadataRequest": {
|
||||
"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"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentsdk.PostStartupRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -8915,6 +9016,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadataDescription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "integer"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentStartupLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
125
coderd/apidoc/swagger.json
generated
125
coderd/apidoc/swagger.json
generated
@ -3747,7 +3747,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/me/metadata": {
|
||||
"/workspaceagents/me/manifest": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
@ -3756,18 +3756,58 @@
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Agents"],
|
||||
"summary": "Get authorized workspace agent metadata",
|
||||
"operationId": "get-authorized-workspace-agent-metadata",
|
||||
"summary": "Get authorized workspace agent manifest",
|
||||
"operationId": "get-authorized-workspace-agent-manifest",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/agentsdk.Metadata"
|
||||
"$ref": "#/definitions/agentsdk.Manifest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/me/metadata/{key}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Agents"],
|
||||
"summary": "Submit workspace agent metadata",
|
||||
"operationId": "submit-workspace-agent-metadata",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Workspace agent metadata request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/agentsdk.PostMetadataRequest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "string",
|
||||
"description": "metadata key",
|
||||
"name": "key",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/me/report-lifecycle": {
|
||||
"post": {
|
||||
"security": [
|
||||
@ -4101,6 +4141,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceagents/{workspaceagent}/watch-metadata": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"tags": ["Agents"],
|
||||
"summary": "Watch for workspace agent metadata updates",
|
||||
"operationId": "watch-for-workspace-agent-metadata-updates",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Workspace agent ID",
|
||||
"name": "workspaceagent",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspacebuilds/{workspacebuild}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -4758,7 +4828,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentsdk.Metadata": {
|
||||
"agentsdk.Manifest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apps": {
|
||||
@ -4783,6 +4853,12 @@
|
||||
"description": "GitAuthConfigs stores the number of Git configurations\nthe Coder deployment has. If this number is \u003e0, we\nset up special configuration in the workspace.",
|
||||
"type": "integer"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceAgentMetadataDescription"
|
||||
}
|
||||
},
|
||||
"motd_file": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -4834,6 +4910,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentsdk.PostMetadataRequest": {
|
||||
"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"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"agentsdk.PostStartupRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -8043,6 +8138,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentMetadataDescription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "integer"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"script": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.WorkspaceAgentStartupLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -608,7 +608,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("/metadata", api.workspaceAgentMetadata)
|
||||
r.Get("/manifest", api.workspaceAgentManifest)
|
||||
// This route is deprecated and will be removed in a future release.
|
||||
// New agents will use /me/manifest instead.
|
||||
r.Get("/metadata", api.workspaceAgentManifest)
|
||||
r.Post("/startup", api.postWorkspaceAgentStartup)
|
||||
r.Patch("/startup-logs", api.patchWorkspaceAgentStartupLogs)
|
||||
r.Post("/app-health", api.postWorkspaceAppHealth)
|
||||
@ -617,6 +620,7 @@ 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)
|
||||
})
|
||||
// No middleware on the PTY endpoint since it uses workspace
|
||||
// application auth and tickets.
|
||||
@ -628,6 +632,8 @@ func New(options *Options) *API {
|
||||
httpmw.ExtractWorkspaceParam(options.Database),
|
||||
)
|
||||
r.Get("/", api.workspaceAgent)
|
||||
r.Get("/watch-metadata", api.watchWorkspaceAgentMetadata)
|
||||
r.Get("/pty", api.workspaceAgentPTY)
|
||||
r.Get("/startup-logs", api.workspaceAgentStartupLogs)
|
||||
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
|
||||
r.Get("/connection", api.workspaceAgentConnection)
|
||||
|
@ -160,6 +160,11 @@ func VerifySwaggerDefinitions(t *testing.T, router chi.Router, swaggerComments [
|
||||
t.Run(method+" "+route, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This route is for compatibility purposes and is not documented.
|
||||
if route == "/workspaceagents/me/metadata" {
|
||||
return
|
||||
}
|
||||
|
||||
c := findSwaggerCommentByMethodAndRoute(swaggerComments, method, route)
|
||||
assert.NotNil(t, c, "Missing @Router annotation")
|
||||
if c == nil {
|
||||
|
@ -1564,6 +1564,44 @@ func (q *querier) InsertWorkspaceAgentStat(ctx context.Context, arg database.Ins
|
||||
return q.db.InsertWorkspaceAgentStat(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertWorkspaceAgentMetadata(ctx context.Context, arg database.InsertWorkspaceAgentMetadataParams) error {
|
||||
// We don't check for workspace ownership here since the agent metadata may
|
||||
// be associated with an orphaned agent used by a dry run build.
|
||||
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.InsertWorkspaceAgentMetadata(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceAgentMetadata(ctx context.Context, arg database.UpdateWorkspaceAgentMetadataParams) error {
|
||||
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.WorkspaceAgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = q.authorizeContext(ctx, rbac.ActionUpdate, workspace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.db.UpdateWorkspaceAgentMetadata(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) {
|
||||
workspace, err := q.db.GetWorkspaceByAgentID(ctx, workspaceAgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = q.authorizeContext(ctx, rbac.ActionRead, workspace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q.db.GetWorkspaceAgentMetadata(ctx, workspaceAgentID)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error {
|
||||
// TODO: This is a workspace agent operation. Should users be able to query this?
|
||||
workspace, err := q.db.GetWorkspaceByWorkspaceAppID(ctx, arg.ID)
|
||||
|
@ -124,6 +124,7 @@ type data struct {
|
||||
templateVersionVariables []database.TemplateVersionVariable
|
||||
templates []database.Template
|
||||
workspaceAgents []database.WorkspaceAgent
|
||||
workspaceAgentMetadata []database.WorkspaceAgentMetadatum
|
||||
workspaceAgentLogs []database.WorkspaceAgentStartupLog
|
||||
workspaceApps []database.WorkspaceApp
|
||||
workspaceBuilds []database.WorkspaceBuild
|
||||
@ -2741,6 +2742,60 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateWorkspaceAgentMetadata(_ context.Context, arg database.UpdateWorkspaceAgentMetadataParams) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertWorkspaceAgentMetadata(_ context.Context, arg database.InsertWorkspaceAgentMetadataParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
//nolint:gosimple
|
||||
metadatum := database.WorkspaceAgentMetadatum{
|
||||
WorkspaceAgentID: arg.WorkspaceAgentID,
|
||||
Script: arg.Script,
|
||||
DisplayName: arg.DisplayName,
|
||||
Key: arg.Key,
|
||||
Timeout: arg.Timeout,
|
||||
Interval: arg.Interval,
|
||||
}
|
||||
|
||||
q.workspaceAgentMetadata = append(q.workspaceAgentMetadata, metadatum)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetWorkspaceAgentMetadata(_ context.Context, workspaceAgentID uuid.UUID) ([]database.WorkspaceAgentMetadatum, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
metadata := make([]database.WorkspaceAgentMetadatum, 0)
|
||||
for _, m := range q.workspaceAgentMetadata {
|
||||
if m.WorkspaceAgentID == workspaceAgentID {
|
||||
metadata = append(metadata, m)
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertFile(_ context.Context, arg database.InsertFileParams) (database.File, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.File{}, err
|
||||
|
18
coderd/database/dump.sql
generated
18
coderd/database/dump.sql
generated
@ -475,6 +475,18 @@ CREATE TABLE users (
|
||||
last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNLOGGED TABLE workspace_agent_metadata (
|
||||
workspace_agent_id uuid NOT NULL,
|
||||
display_name character varying(127) NOT NULL,
|
||||
key character varying(127) NOT NULL,
|
||||
script character varying(65535) NOT NULL,
|
||||
value character varying(65535) DEFAULT ''::character varying NOT NULL,
|
||||
error character varying(65535) DEFAULT ''::character varying NOT NULL,
|
||||
timeout bigint NOT NULL,
|
||||
"interval" bigint NOT NULL,
|
||||
collected_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_agent_startup_logs (
|
||||
agent_id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
@ -756,6 +768,9 @@ ALTER TABLE ONLY user_links
|
||||
ALTER TABLE ONLY users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_metadata
|
||||
ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key);
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_startup_logs
|
||||
ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id);
|
||||
|
||||
@ -894,6 +909,9 @@ ALTER TABLE ONLY templates
|
||||
ALTER TABLE ONLY user_links
|
||||
ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_metadata
|
||||
ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_agent_startup_logs
|
||||
ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
DROP TABLE workspace_agent_metadata;
|
@ -0,0 +1,16 @@
|
||||
-- This table is UNLOGGED because it is very update-heavy and the the data
|
||||
-- is not valuable enough to justify the overhead of WAL logging. This should
|
||||
-- give us a ~70% improvement in write throughput.
|
||||
CREATE UNLOGGED TABLE workspace_agent_metadata (
|
||||
workspace_agent_id uuid NOT NULL,
|
||||
display_name varchar(127) NOT NULL,
|
||||
key varchar(127) NOT NULL,
|
||||
script varchar(65535) NOT NULL,
|
||||
value varchar(65535) NOT NULL DEFAULT '',
|
||||
error varchar(65535) NOT NULL DEFAULT '',
|
||||
timeout bigint NOT NULL,
|
||||
interval bigint NOT NULL,
|
||||
collected_at timestamp with time zone NOT NULL DEFAULT '0001-01-01 00:00:00+00',
|
||||
PRIMARY KEY (workspace_agent_id, key),
|
||||
FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE
|
||||
);
|
18
coderd/database/migrations/testdata/fixtures/000111_workspace_agent_metadata.up.sql
vendored
Normal file
18
coderd/database/migrations/testdata/fixtures/000111_workspace_agent_metadata.up.sql
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
INSERT INTO
|
||||
workspace_agent_metadata (
|
||||
workspace_agent_id,
|
||||
display_name,
|
||||
key,
|
||||
script,
|
||||
timeout,
|
||||
interval
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'45e89705-e09d-4850-bcec-f9a937f5d78d',
|
||||
'a h e m',
|
||||
'ahem',
|
||||
'rm -rf',
|
||||
3,
|
||||
1
|
||||
);
|
@ -1575,6 +1575,18 @@ type WorkspaceAgent struct {
|
||||
StartupLogsOverflowed bool `db:"startup_logs_overflowed" json:"startup_logs_overflowed"`
|
||||
}
|
||||
|
||||
type WorkspaceAgentMetadatum struct {
|
||||
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Key string `db:"key" json:"key"`
|
||||
Script string `db:"script" json:"script"`
|
||||
Value string `db:"value" json:"value"`
|
||||
Error string `db:"error" json:"error"`
|
||||
Timeout int64 `db:"timeout" json:"timeout"`
|
||||
Interval int64 `db:"interval" json:"interval"`
|
||||
CollectedAt time.Time `db:"collected_at" json:"collected_at"`
|
||||
}
|
||||
|
||||
type WorkspaceAgentStartupLog struct {
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
@ -126,6 +126,7 @@ type sqlcQuerier interface {
|
||||
GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error)
|
||||
GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error)
|
||||
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
|
||||
GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentMetadatum, error)
|
||||
GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg GetWorkspaceAgentStartupLogsAfterParams) ([]WorkspaceAgentStartupLog, error)
|
||||
GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error)
|
||||
GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error)
|
||||
@ -185,6 +186,7 @@ type sqlcQuerier interface {
|
||||
InsertUserLink(ctx context.Context, arg InsertUserLinkParams) (UserLink, error)
|
||||
InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error)
|
||||
InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspaceAgentParams) (WorkspaceAgent, error)
|
||||
InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error
|
||||
InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error)
|
||||
InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error)
|
||||
InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error)
|
||||
@ -229,6 +231,7 @@ type sqlcQuerier interface {
|
||||
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
|
||||
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
|
||||
UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error
|
||||
UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error
|
||||
UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error
|
||||
UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentStartupLogOverflowByIDParams) error
|
||||
UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error
|
||||
|
@ -5297,6 +5297,48 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceAgentMetadata = `-- name: GetWorkspaceAgentMetadata :many
|
||||
SELECT
|
||||
workspace_agent_id, display_name, key, script, value, error, timeout, interval, collected_at
|
||||
FROM
|
||||
workspace_agent_metadata
|
||||
WHERE
|
||||
workspace_agent_id = $1
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, workspaceAgentID uuid.UUID) ([]WorkspaceAgentMetadatum, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspaceAgentMetadata, workspaceAgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceAgentMetadatum
|
||||
for rows.Next() {
|
||||
var i WorkspaceAgentMetadatum
|
||||
if err := rows.Scan(
|
||||
&i.WorkspaceAgentID,
|
||||
&i.DisplayName,
|
||||
&i.Key,
|
||||
&i.Script,
|
||||
&i.Value,
|
||||
&i.Error,
|
||||
&i.Timeout,
|
||||
&i.Interval,
|
||||
&i.CollectedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getWorkspaceAgentStartupLogsAfter = `-- name: GetWorkspaceAgentStartupLogsAfter :many
|
||||
SELECT
|
||||
agent_id, created_at, output, id
|
||||
@ -5651,6 +5693,41 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertWorkspaceAgentMetadata = `-- name: InsertWorkspaceAgentMetadata :exec
|
||||
INSERT INTO
|
||||
workspace_agent_metadata (
|
||||
workspace_agent_id,
|
||||
display_name,
|
||||
key,
|
||||
script,
|
||||
timeout,
|
||||
interval
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6)
|
||||
`
|
||||
|
||||
type InsertWorkspaceAgentMetadataParams struct {
|
||||
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Key string `db:"key" json:"key"`
|
||||
Script string `db:"script" json:"script"`
|
||||
Timeout int64 `db:"timeout" json:"timeout"`
|
||||
Interval int64 `db:"interval" json:"interval"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertWorkspaceAgentMetadata,
|
||||
arg.WorkspaceAgentID,
|
||||
arg.DisplayName,
|
||||
arg.Key,
|
||||
arg.Script,
|
||||
arg.Timeout,
|
||||
arg.Interval,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertWorkspaceAgentStartupLogs = `-- name: InsertWorkspaceAgentStartupLogs :many
|
||||
WITH new_length AS (
|
||||
UPDATE workspace_agents SET
|
||||
@ -5758,6 +5835,37 @@ func (q *sqlQuerier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context,
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceAgentMetadata = `-- name: UpdateWorkspaceAgentMetadata :exec
|
||||
UPDATE
|
||||
workspace_agent_metadata
|
||||
SET
|
||||
value = $3,
|
||||
error = $4,
|
||||
collected_at = $5
|
||||
WHERE
|
||||
workspace_agent_id = $1
|
||||
AND key = $2
|
||||
`
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceAgentStartupByID = `-- name: UpdateWorkspaceAgentStartupByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
|
@ -94,6 +94,38 @@ SET
|
||||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: InsertWorkspaceAgentMetadata :exec
|
||||
INSERT INTO
|
||||
workspace_agent_metadata (
|
||||
workspace_agent_id,
|
||||
display_name,
|
||||
key,
|
||||
script,
|
||||
timeout,
|
||||
interval
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: UpdateWorkspaceAgentMetadata :exec
|
||||
UPDATE
|
||||
workspace_agent_metadata
|
||||
SET
|
||||
value = $3,
|
||||
error = $4,
|
||||
collected_at = $5
|
||||
WHERE
|
||||
workspace_agent_id = $1
|
||||
AND key = $2;
|
||||
|
||||
-- name: GetWorkspaceAgentMetadata :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_agent_metadata
|
||||
WHERE
|
||||
workspace_agent_id = $1;
|
||||
|
||||
-- name: UpdateWorkspaceAgentStartupLogOverflowByID :exec
|
||||
UPDATE
|
||||
workspace_agents
|
||||
|
@ -1277,6 +1277,21 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
|
||||
}
|
||||
snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, telemetry.ConvertWorkspaceAgent(dbAgent))
|
||||
|
||||
for _, md := range prAgent.Metadata {
|
||||
p := database.InsertWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: agentID,
|
||||
DisplayName: md.DisplayName,
|
||||
Script: md.Script,
|
||||
Key: md.Key,
|
||||
Timeout: md.Timeout,
|
||||
Interval: md.Interval,
|
||||
}
|
||||
err := db.InsertWorkspaceAgentMetadata(ctx, p)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert agent metadata: %w, params: %+v", err, p)
|
||||
}
|
||||
}
|
||||
|
||||
for _, app := range prAgent.Apps {
|
||||
slug := app.Slug
|
||||
if slug == "" {
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
@ -76,14 +77,14 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, apiAgent)
|
||||
}
|
||||
|
||||
// @Summary Get authorized workspace agent metadata
|
||||
// @ID get-authorized-workspace-agent-metadata
|
||||
// @Summary Get authorized workspace agent manifest
|
||||
// @ID get-authorized-workspace-agent-manifest
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Agents
|
||||
// @Success 200 {object} agentsdk.Metadata
|
||||
// @Router /workspaceagents/me/metadata [get]
|
||||
func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Success 200 {object} agentsdk.Manifest
|
||||
// @Router /workspaceagents/me/manifest [get]
|
||||
func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
apiAgent, err := convertWorkspaceAgent(
|
||||
@ -105,6 +106,16 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace agent metadata.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@ -149,7 +160,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
|
||||
vscodeProxyURI += fmt.Sprintf(":%s", api.AccessURL.Port())
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Metadata{
|
||||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{
|
||||
Apps: convertApps(dbApps),
|
||||
DERPMap: api.DERPMap,
|
||||
GitAuthConfigs: len(api.GitAuthConfigs),
|
||||
@ -161,6 +172,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request)
|
||||
StartupScriptTimeout: time.Duration(apiAgent.StartupScriptTimeoutSeconds) * time.Second,
|
||||
ShutdownScript: apiAgent.ShutdownScript,
|
||||
ShutdownScriptTimeout: time.Duration(apiAgent.ShutdownScriptTimeoutSeconds) * time.Second,
|
||||
Metadata: convertWorkspaceAgentMetadataDesc(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1133,6 +1145,20 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
|
||||
return apps
|
||||
}
|
||||
|
||||
func convertWorkspaceAgentMetadataDesc(mds []database.WorkspaceAgentMetadatum) []codersdk.WorkspaceAgentMetadataDescription {
|
||||
metadata := make([]codersdk.WorkspaceAgentMetadataDescription, 0)
|
||||
for _, datum := range mds {
|
||||
metadata = append(metadata, codersdk.WorkspaceAgentMetadataDescription{
|
||||
DisplayName: datum.DisplayName,
|
||||
Key: datum.Key,
|
||||
Script: datum.Script,
|
||||
Interval: datum.Interval,
|
||||
Timeout: datum.Timeout,
|
||||
})
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator, dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, agentInactiveDisconnectTimeout time.Duration, agentFallbackTroubleshootingURL string) (codersdk.WorkspaceAgent, error) {
|
||||
var envs map[string]string
|
||||
if dbAgent.EnvironmentVariables.Valid {
|
||||
@ -1298,6 +1324,219 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Submit workspace agent metadata
|
||||
// @ID submit-workspace-agent-metadata
|
||||
// @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)
|
||||
// @Success 204 "Success"
|
||||
// @Router /workspaceagents/me/metadata/{key} [post]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) workspaceAgentPostMetadata(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req agentsdk.PostMetadataRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
|
||||
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to get workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
const (
|
||||
maxValueLen = 32 << 10
|
||||
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]
|
||||
}
|
||||
|
||||
datum := database.UpdateWorkspaceAgentMetadataParams{
|
||||
WorkspaceAgentID: workspaceAgent.ID,
|
||||
// We don't want a misconfigured agent to fill the database.
|
||||
Key: key,
|
||||
Value: req.Value,
|
||||
Error: metadataError,
|
||||
// We ignore the CollectedAt from the agent to avoid bugs caused by
|
||||
// clock skew.
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
err = api.Database.UpdateWorkspaceAgentMetadata(ctx, datum)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
api.Logger.Debug(
|
||||
ctx, "accepted metadata report",
|
||||
slog.F("agent", workspaceAgent.ID),
|
||||
slog.F("workspace", workspace.ID),
|
||||
slog.F("collected_at", datum.CollectedAt),
|
||||
slog.F("key", datum.Key),
|
||||
)
|
||||
|
||||
err = api.Pubsub.Publish(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), []byte(datum.Key))
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// @Summary Watch for workspace agent metadata updates
|
||||
// @ID watch-for-workspace-agent-metadata-updates
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Agents
|
||||
// @Success 200 "Success"
|
||||
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
||||
// @Router /workspaceagents/{workspaceagent}/watch-metadata [get]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) watchWorkspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
workspaceAgent = httpmw.WorkspaceAgentParam(r)
|
||||
)
|
||||
|
||||
sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error setting up server-sent events.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Prevent handler from returning until the sender is closed.
|
||||
defer func() {
|
||||
<-senderClosed
|
||||
}()
|
||||
|
||||
// We don't want this intentionally long request to skew our tracing
|
||||
// reports.
|
||||
ctx = trace.ContextWithSpan(ctx, tracing.NoopSpan)
|
||||
|
||||
const refreshInterval = time.Second * 5
|
||||
refreshTicker := time.NewTicker(refreshInterval)
|
||||
defer refreshTicker.Stop()
|
||||
|
||||
var (
|
||||
lastDBMetaMu sync.Mutex
|
||||
lastDBMeta []database.WorkspaceAgentMetadatum
|
||||
)
|
||||
|
||||
sendMetadata := func(pull bool) {
|
||||
lastDBMetaMu.Lock()
|
||||
defer lastDBMetaMu.Unlock()
|
||||
|
||||
var err error
|
||||
if pull {
|
||||
// We always use the original Request context because it contains
|
||||
// the RBAC actor.
|
||||
lastDBMeta, err = api.Database.GetWorkspaceAgentMetadata(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
||||
Type: codersdk.ServerSentEventTypeError,
|
||||
Data: codersdk.Response{
|
||||
Message: "Internal error getting metadata.",
|
||||
Detail: err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
slices.SortFunc(lastDBMeta, func(i, j database.WorkspaceAgentMetadatum) bool {
|
||||
return i.Key < j.Key
|
||||
})
|
||||
|
||||
// Avoid sending refresh if the client is about to get a
|
||||
// fresh update.
|
||||
refreshTicker.Reset(refreshInterval)
|
||||
}
|
||||
|
||||
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
||||
Type: codersdk.ServerSentEventTypeData,
|
||||
Data: convertWorkspaceAgentMetadata(lastDBMeta),
|
||||
})
|
||||
}
|
||||
|
||||
// Send initial metadata.
|
||||
sendMetadata(true)
|
||||
|
||||
// Send metadata on updates.
|
||||
cancelSub, err := api.Pubsub.Subscribe(watchWorkspaceAgentMetadataChannel(workspaceAgent.ID), func(_ context.Context, _ []byte) {
|
||||
sendMetadata(true)
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
defer cancelSub()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-senderClosed:
|
||||
return
|
||||
case <-refreshTicker.C:
|
||||
break
|
||||
}
|
||||
|
||||
// Avoid spamming the DB with reads we know there are no updates. We want
|
||||
// to continue sending updates to the frontend so that "Result.Age"
|
||||
// is always accurate. This way, the frontend doesn't need to
|
||||
// sync its own clock with the backend.
|
||||
sendMetadata(false)
|
||||
}
|
||||
}
|
||||
|
||||
func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []codersdk.WorkspaceAgentMetadata {
|
||||
// An empty array is easier for clients to handle than a null.
|
||||
result := []codersdk.WorkspaceAgentMetadata{}
|
||||
for _, datum := range db {
|
||||
result = append(result, codersdk.WorkspaceAgentMetadata{
|
||||
Result: codersdk.WorkspaceAgentMetadataResult{
|
||||
Value: datum.Value,
|
||||
Error: datum.Error,
|
||||
CollectedAt: datum.CollectedAt,
|
||||
Age: int64(time.Since(datum.CollectedAt).Seconds()),
|
||||
},
|
||||
Description: codersdk.WorkspaceAgentMetadataDescription{
|
||||
DisplayName: datum.DisplayName,
|
||||
Key: datum.Key,
|
||||
Script: datum.Script,
|
||||
Interval: datum.Interval,
|
||||
Timeout: datum.Timeout,
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func watchWorkspaceAgentMetadataChannel(id uuid.UUID) string {
|
||||
return "workspace_agent_metadata:" + id.String()
|
||||
}
|
||||
|
||||
// @Summary Submit workspace agent lifecycle state
|
||||
// @ID submit-workspace-agent-lifecycle-state
|
||||
// @Security CoderSessionToken
|
||||
|
@ -831,10 +831,10 @@ func TestWorkspaceAgentAppHealth(t *testing.T) {
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
|
||||
metadata, err := agentClient.Metadata(ctx)
|
||||
manifest, err := agentClient.Manifest(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, metadata.Apps[0].Health)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, metadata.Apps[1].Health)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, manifest.Apps[0].Health)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, manifest.Apps[1].Health)
|
||||
err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{})
|
||||
require.Error(t, err)
|
||||
// empty
|
||||
@ -843,37 +843,37 @@ func TestWorkspaceAgentAppHealth(t *testing.T) {
|
||||
// healthcheck disabled
|
||||
err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{
|
||||
metadata.Apps[0].ID: codersdk.WorkspaceAppHealthInitializing,
|
||||
manifest.Apps[0].ID: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// invalid value
|
||||
err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{
|
||||
metadata.Apps[1].ID: codersdk.WorkspaceAppHealth("bad-value"),
|
||||
manifest.Apps[1].ID: codersdk.WorkspaceAppHealth("bad-value"),
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
// update to healthy
|
||||
err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{
|
||||
metadata.Apps[1].ID: codersdk.WorkspaceAppHealthHealthy,
|
||||
manifest.Apps[1].ID: codersdk.WorkspaceAppHealthHealthy,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
metadata, err = agentClient.Metadata(ctx)
|
||||
manifest, err = agentClient.Manifest(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthHealthy, metadata.Apps[1].Health)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthHealthy, manifest.Apps[1].Health)
|
||||
// update to unhealthy
|
||||
err = agentClient.PostAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: map[uuid.UUID]codersdk.WorkspaceAppHealth{
|
||||
metadata.Apps[1].ID: codersdk.WorkspaceAppHealthUnhealthy,
|
||||
manifest.Apps[1].ID: codersdk.WorkspaceAppHealthUnhealthy,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
metadata, err = agentClient.Metadata(ctx)
|
||||
manifest, err = agentClient.Manifest(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, metadata.Apps[1].Health)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthUnhealthy, manifest.Apps[1].Health)
|
||||
}
|
||||
|
||||
// nolint:bodyclose
|
||||
@ -1262,3 +1262,155 @@ func TestWorkspaceAgent_LifecycleState(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceAgent_Metadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Metadata: []*proto.Agent_Metadata{
|
||||
{
|
||||
DisplayName: "First Meta",
|
||||
Key: "foo1",
|
||||
Script: "echo hi",
|
||||
Interval: 10,
|
||||
Timeout: 3,
|
||||
},
|
||||
{
|
||||
DisplayName: "Second Meta",
|
||||
Key: "foo2",
|
||||
Script: "echo howdy",
|
||||
Interval: 10,
|
||||
Timeout: 3,
|
||||
},
|
||||
{
|
||||
DisplayName: "TooLong",
|
||||
Key: "foo3",
|
||||
Script: "echo howdy",
|
||||
Interval: 10,
|
||||
Timeout: 3,
|
||||
},
|
||||
},
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
for _, res := range workspace.LatestBuild.Resources {
|
||||
for _, a := range res.Agents {
|
||||
require.Equal(t, codersdk.WorkspaceAgentLifecycleCreated, a.LifecycleState)
|
||||
}
|
||||
}
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
manifest, err := agentClient.Manifest(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify manifest API response.
|
||||
require.Equal(t, "First Meta", manifest.Metadata[0].DisplayName)
|
||||
require.Equal(t, "foo1", manifest.Metadata[0].Key)
|
||||
require.Equal(t, "echo hi", manifest.Metadata[0].Script)
|
||||
require.EqualValues(t, 10, manifest.Metadata[0].Interval)
|
||||
require.EqualValues(t, 3, manifest.Metadata[0].Timeout)
|
||||
|
||||
post := func(key string, mr codersdk.WorkspaceAgentMetadataResult) {
|
||||
err := agentClient.PostMetadata(ctx, key, mr)
|
||||
require.NoError(t, err, "post metadata", t)
|
||||
}
|
||||
|
||||
workspace, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "get workspace")
|
||||
|
||||
agentID := workspace.LatestBuild.Resources[0].Agents[0].ID
|
||||
|
||||
var update []codersdk.WorkspaceAgentMetadata
|
||||
|
||||
check := func(want codersdk.WorkspaceAgentMetadataResult, got codersdk.WorkspaceAgentMetadata) {
|
||||
require.WithinDuration(t, want.CollectedAt, got.Result.CollectedAt, time.Second)
|
||||
require.WithinDuration(
|
||||
t, time.Now(), got.Result.CollectedAt.Add(time.Duration(got.Result.Age)*time.Second), time.Millisecond*500,
|
||||
)
|
||||
require.Equal(t, want.Value, got.Result.Value)
|
||||
require.Equal(t, want.Error, got.Result.Error)
|
||||
}
|
||||
|
||||
wantMetadata1 := codersdk.WorkspaceAgentMetadataResult{
|
||||
CollectedAt: time.Now(),
|
||||
Value: "bar",
|
||||
}
|
||||
|
||||
// Initial post must come before the Watch is established.
|
||||
post("foo1", wantMetadata1)
|
||||
|
||||
updates, errors := client.WatchWorkspaceAgentMetadata(ctx, agentID)
|
||||
|
||||
recvUpdate := func() []codersdk.WorkspaceAgentMetadata {
|
||||
select {
|
||||
case err := <-errors:
|
||||
t.Fatalf("error watching metadata: %v", err)
|
||||
return nil
|
||||
case update := <-updates:
|
||||
return update
|
||||
}
|
||||
}
|
||||
|
||||
update = recvUpdate()
|
||||
require.Len(t, update, 3)
|
||||
check(wantMetadata1, update[0])
|
||||
// The second metadata result is not yet posted.
|
||||
require.Zero(t, update[1].Result.CollectedAt)
|
||||
|
||||
wantMetadata2 := wantMetadata1
|
||||
post("foo2", wantMetadata2)
|
||||
update = recvUpdate()
|
||||
require.Len(t, update, 3)
|
||||
check(wantMetadata1, update[0])
|
||||
check(wantMetadata2, update[1])
|
||||
|
||||
wantMetadata1.Error = "error"
|
||||
post("foo1", wantMetadata1)
|
||||
update = recvUpdate()
|
||||
require.Len(t, update, 3)
|
||||
check(wantMetadata1, update[0])
|
||||
|
||||
const maxValueLen = 32 << 10
|
||||
tooLongValueMetadata := wantMetadata1
|
||||
tooLongValueMetadata.Value = strings.Repeat("a", maxValueLen*2)
|
||||
tooLongValueMetadata.Error = ""
|
||||
tooLongValueMetadata.CollectedAt = time.Now()
|
||||
post("foo3", tooLongValueMetadata)
|
||||
got := recvUpdate()[2]
|
||||
require.Len(t, got.Result.Value, maxValueLen)
|
||||
require.NotEmpty(t, got.Result.Error)
|
||||
|
||||
unknownKeyMetadata := wantMetadata1
|
||||
err = agentClient.PostMetadata(ctx, "unknown", unknownKeyMetadata)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
@ -273,7 +273,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
if appHost != "" {
|
||||
metadata, err := agentClient.Metadata(context.Background())
|
||||
manifest, err := agentClient.Manifest(context.Background())
|
||||
require.NoError(t, err)
|
||||
proxyURL := fmt.Sprintf(
|
||||
"http://{{port}}--%s--%s--%s%s",
|
||||
@ -285,7 +285,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
if client.URL.Port() != "" {
|
||||
proxyURL += fmt.Sprintf(":%s", client.URL.Port())
|
||||
}
|
||||
require.Equal(t, proxyURL, metadata.VSCodePortProxyURI)
|
||||
require.Equal(t, proxyURL, manifest.VSCodePortProxyURI)
|
||||
}
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
|
@ -41,7 +41,7 @@ func TestCache(t *testing.T) {
|
||||
t.Run("Same", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
|
||||
return setupAgent(t, agentsdk.Metadata{}, 0), nil
|
||||
return setupAgent(t, agentsdk.Manifest{}, 0), nil
|
||||
}, 0)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -57,7 +57,7 @@ func TestCache(t *testing.T) {
|
||||
called := atomic.NewInt32(0)
|
||||
cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
|
||||
called.Add(1)
|
||||
return setupAgent(t, agentsdk.Metadata{}, 0), nil
|
||||
return setupAgent(t, agentsdk.Manifest{}, 0), nil
|
||||
}, time.Microsecond)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -75,7 +75,7 @@ func TestCache(t *testing.T) {
|
||||
t.Run("NoExpireWhenLocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
|
||||
return setupAgent(t, agentsdk.Metadata{}, 0), nil
|
||||
return setupAgent(t, agentsdk.Manifest{}, 0), nil
|
||||
}, time.Microsecond)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -108,7 +108,7 @@ func TestCache(t *testing.T) {
|
||||
go server.Serve(random)
|
||||
|
||||
cache := wsconncache.New(func(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
|
||||
return setupAgent(t, agentsdk.Metadata{}, 0), nil
|
||||
return setupAgent(t, agentsdk.Manifest{}, 0), nil
|
||||
}, time.Microsecond)
|
||||
defer func() {
|
||||
_ = cache.Close()
|
||||
@ -154,10 +154,10 @@ func TestCache(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Duration) *codersdk.WorkspaceAgentConn {
|
||||
func setupAgent(t *testing.T, manifest agentsdk.Manifest, ptyTimeout time.Duration) *codersdk.WorkspaceAgentConn {
|
||||
t.Helper()
|
||||
|
||||
metadata.DERPMap = tailnettest.RunDERPAndSTUN(t)
|
||||
manifest.DERPMap = tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
coordinator := tailnet.NewCoordinator()
|
||||
t.Cleanup(func() {
|
||||
@ -168,7 +168,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati
|
||||
Client: &client{
|
||||
t: t,
|
||||
agentID: agentID,
|
||||
metadata: metadata,
|
||||
manifest: manifest,
|
||||
coordinator: coordinator,
|
||||
},
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelInfo),
|
||||
@ -179,7 +179,7 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati
|
||||
})
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)},
|
||||
DERPMap: metadata.DERPMap,
|
||||
DERPMap: manifest.DERPMap,
|
||||
Logger: slogtest.Make(t, nil).Named("tailnet").Leveled(slog.LevelDebug),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@ -211,12 +211,12 @@ func setupAgent(t *testing.T, metadata agentsdk.Metadata, ptyTimeout time.Durati
|
||||
type client struct {
|
||||
t *testing.T
|
||||
agentID uuid.UUID
|
||||
metadata agentsdk.Metadata
|
||||
manifest agentsdk.Manifest
|
||||
coordinator tailnet.Coordinator
|
||||
}
|
||||
|
||||
func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) {
|
||||
return c.metadata, nil
|
||||
func (c *client) Manifest(_ context.Context) (agentsdk.Manifest, error) {
|
||||
return c.manifest, nil
|
||||
}
|
||||
|
||||
func (c *client) Listen(_ context.Context) (net.Conn, error) {
|
||||
@ -246,6 +246,10 @@ func (*client) PostAppHealth(_ context.Context, _ agentsdk.PostAppHealthsRequest
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*client) PostMetadata(_ context.Context, _ string, _ agentsdk.PostMetadataRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*client) PostStartup(_ context.Context, _ agentsdk.PostStartupRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user