From 99d510cbf9708554dd2921dbc36cbfa58cedf696 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 22 Feb 2023 17:12:23 -0900 Subject: [PATCH 01/29] Add startup script logs to the database --- coderd/database/dbauthz/querier.go | 21 +++++++ coderd/database/dbauthz/querier_test.go | 16 ++++++ coderd/database/dbfake/databasefake.go | 40 ++++++++++++++ coderd/database/dump.sql | 15 +++++ .../000100_add_startup_logs.down.sql | 1 + .../migrations/000100_add_startup_logs.up.sql | 6 ++ coderd/database/models.go | 6 ++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 55 +++++++++++++++++++ coderd/database/queries/startupscriptlogs.sql | 18 ++++++ coderd/database/unique_constraint.go | 1 + 11 files changed, 181 insertions(+) create mode 100644 coderd/database/migrations/000100_add_startup_logs.down.sql create mode 100644 coderd/database/migrations/000100_add_startup_logs.up.sql create mode 100644 coderd/database/queries/startupscriptlogs.sql diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 91e912cb90271..a4eb93e14c73d 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1263,6 +1263,27 @@ func (q *querier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg datab return q.db.UpdateWorkspaceAgentStartupByID(ctx, arg) } +func (q *querier) GetStartupScriptLogsByJobID(ctx context.Context, jobID uuid.UUID) ([]database.StartupScriptLog, error) { + build, err := q.db.GetWorkspaceBuildByJobID(ctx, jobID) + if err != nil { + return nil, err + } + // Authorized fetch + _, err = q.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return nil, err + } + return q.db.GetStartupScriptLogsByJobID(ctx, jobID) +} + +func (q *querier) InsertOrUpdateStartupScriptLog(ctx context.Context, arg database.InsertOrUpdateStartupScriptLogParams) error { + // Authorized fetch + if _, err := q.GetWorkspaceByAgentID(ctx, arg.AgentID); err != nil { + return err + } + return q.db.InsertOrUpdateStartupScriptLog(ctx, arg) +} + func (q *querier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg database.GetWorkspaceAppByAgentIDAndSlugParams) (database.WorkspaceApp, error) { // If we can fetch the workspace, we can fetch the apps. Use the authorized call. if _, err := q.GetWorkspaceByAgentID(ctx, arg.AgentID); err != nil { diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index 8fc6178001f1d..4fda12cd62b82 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -966,6 +966,22 @@ func (s *MethodTestSuite) TestWorkspace() { ID: agt.ID, }).Asserts(ws, rbac.ActionUpdate).Returns() })) + s.Run("GetStartupScriptLogsByJobID", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.Workspace{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + check.Args(build.JobID).Asserts(ws, rbac.ActionRead).Returns([]database.StartupScriptLog{}) + })) + s.Run("InsertOrUpdateStartupScriptLog", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.Workspace{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) + agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + check.Args(database.InsertOrUpdateStartupScriptLogParams{ + AgentID: agt.ID, + JobID: build.JobID, + Output: "test", + }).Asserts(ws, rbac.ActionUpdate).Returns() + })) s.Run("GetWorkspaceAppByAgentIDAndSlug", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 1426eb494833f..c9fb73438097e 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -53,6 +53,7 @@ func New() database.Store { parameterSchemas: make([]database.ParameterSchema, 0), parameterValues: make([]database.ParameterValue, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), + startupScriptLogs: make([]database.StartupScriptLog, 0), workspaceAgents: make([]database.WorkspaceAgent, 0), provisionerJobLogs: make([]database.ProvisionerJobLog, 0), workspaceResources: make([]database.WorkspaceResource, 0), @@ -112,6 +113,7 @@ type data struct { provisionerJobLogs []database.ProvisionerJobLog provisionerJobs []database.ProvisionerJob replicas []database.Replica + startupScriptLogs []database.StartupScriptLog templateVersions []database.TemplateVersion templateVersionParameters []database.TemplateVersionParameter templateVersionVariables []database.TemplateVersionVariable @@ -3303,6 +3305,44 @@ func (q *fakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } +func (q *fakeQuerier) GetStartupScriptLogsByJobID(_ context.Context, jobID uuid.UUID) ([]database.StartupScriptLog, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + logs := []database.StartupScriptLog{} + for _, log := range q.startupScriptLogs { + if log.JobID == jobID { + logs = append(logs, log) + } + } + return logs, sql.ErrNoRows +} + +func (q *fakeQuerier) InsertOrUpdateStartupScriptLog(_ context.Context, arg database.InsertOrUpdateStartupScriptLogParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, log := range q.startupScriptLogs { + if log.JobID != arg.JobID { + continue + } + + log.Output = arg.Output + q.startupScriptLogs[index] = log + return nil + } + q.startupScriptLogs = append(q.startupScriptLogs, database.StartupScriptLog{ + AgentID: arg.AgentID, + JobID: arg.JobID, + Output: arg.Output, + }) + return nil +} + func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2137be816ef84..04459c09ea1f2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -342,6 +342,12 @@ CREATE TABLE site_configs ( value character varying(8192) NOT NULL ); +CREATE TABLE startup_script_logs ( + agent_id uuid NOT NULL, + job_id uuid NOT NULL, + output text NOT NULL +); + CREATE TABLE template_version_parameters ( template_version_id uuid NOT NULL, name text NOT NULL, @@ -677,6 +683,9 @@ ALTER TABLE ONLY provisioner_jobs ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); +ALTER TABLE ONLY startup_script_logs + ADD CONSTRAINT startup_script_logs_agent_id_job_id_key UNIQUE (agent_id, job_id); + ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); @@ -805,6 +814,12 @@ ALTER TABLE ONLY provisioner_job_logs ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY startup_script_logs + ADD CONSTRAINT startup_script_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY startup_script_logs + ADD CONSTRAINT startup_script_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000100_add_startup_logs.down.sql b/coderd/database/migrations/000100_add_startup_logs.down.sql new file mode 100644 index 0000000000000..07f83067d4fe6 --- /dev/null +++ b/coderd/database/migrations/000100_add_startup_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE startup_script_logs diff --git a/coderd/database/migrations/000100_add_startup_logs.up.sql b/coderd/database/migrations/000100_add_startup_logs.up.sql new file mode 100644 index 0000000000000..86726c6012c66 --- /dev/null +++ b/coderd/database/migrations/000100_add_startup_logs.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS startup_script_logs ( + agent_id uuid NOT NULL REFERENCES workspace_agents (id) ON DELETE CASCADE, + job_id uuid NOT NULL REFERENCES provisioner_jobs (id) ON DELETE CASCADE, + output text NOT NULL, + UNIQUE(agent_id, job_id) +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 18276551a488e..916a2c4032142 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1401,6 +1401,12 @@ type SiteConfig struct { Value string `db:"value" json:"value"` } +type StartupScriptLog struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + JobID uuid.UUID `db:"job_id" json:"job_id"` + Output string `db:"output" json:"output"` +} + type Template struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4f7bf73b4de3e..083ad0c291a90 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -79,6 +79,7 @@ type sqlcQuerier interface { GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) GetServiceBanner(ctx context.Context) (string, error) + GetStartupScriptLogsByJobID(ctx context.Context, jobID uuid.UUID) ([]StartupScriptLog, error) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) @@ -149,6 +150,7 @@ type sqlcQuerier interface { InsertOrUpdateLastUpdateCheck(ctx context.Context, value string) error InsertOrUpdateLogoURL(ctx context.Context, value string) error InsertOrUpdateServiceBanner(ctx context.Context, value string) error + InsertOrUpdateStartupScriptLog(ctx context.Context, arg InsertOrUpdateStartupScriptLogParams) error InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1f1c79bbc624d..993d2c0169762 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3050,6 +3050,61 @@ func (q *sqlQuerier) InsertOrUpdateServiceBanner(ctx context.Context, value stri return err } +const getStartupScriptLogsByJobID = `-- name: GetStartupScriptLogsByJobID :many +SELECT + agent_id, job_id, output +FROM + startup_script_logs +WHERE + job_id = $1 +` + +func (q *sqlQuerier) GetStartupScriptLogsByJobID(ctx context.Context, jobID uuid.UUID) ([]StartupScriptLog, error) { + rows, err := q.db.QueryContext(ctx, getStartupScriptLogsByJobID, jobID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []StartupScriptLog + for rows.Next() { + var i StartupScriptLog + if err := rows.Scan(&i.AgentID, &i.JobID, &i.Output); 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 insertOrUpdateStartupScriptLog = `-- name: InsertOrUpdateStartupScriptLog :exec +INSERT INTO + startup_script_logs (agent_id, job_id, output) +VALUES ($1, $2, $3) +ON CONFLICT (agent_id, job_id) DO UPDATE + SET + output = $3 + WHERE + startup_script_logs.agent_id = $1 + AND startup_script_logs.job_id = $2 +` + +type InsertOrUpdateStartupScriptLogParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + JobID uuid.UUID `db:"job_id" json:"job_id"` + Output string `db:"output" json:"output"` +} + +func (q *sqlQuerier) InsertOrUpdateStartupScriptLog(ctx context.Context, arg InsertOrUpdateStartupScriptLogParams) error { + _, err := q.db.ExecContext(ctx, insertOrUpdateStartupScriptLog, arg.AgentID, arg.JobID, arg.Output) + return err +} + const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT diff --git a/coderd/database/queries/startupscriptlogs.sql b/coderd/database/queries/startupscriptlogs.sql new file mode 100644 index 0000000000000..8796b48965248 --- /dev/null +++ b/coderd/database/queries/startupscriptlogs.sql @@ -0,0 +1,18 @@ +-- name: GetStartupScriptLogsByJobID :many +SELECT + * +FROM + startup_script_logs +WHERE + job_id = $1; + +-- name: InsertOrUpdateStartupScriptLog :exec +INSERT INTO + startup_script_logs (agent_id, job_id, output) +VALUES ($1, $2, $3) +ON CONFLICT (agent_id, job_id) DO UPDATE + SET + output = $3 + WHERE + startup_script_logs.agent_id = $1 + AND startup_script_logs.job_id = $2; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 6bf2abb04faee..9925c64cc8834 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -15,6 +15,7 @@ const ( UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); UniqueProvisionerDaemonsNameKey UniqueConstraint = "provisioner_daemons_name_key" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_name_key UNIQUE (name); UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); + UniqueStartupScriptLogsAgentIDJobIDKey UniqueConstraint = "startup_script_logs_agent_id_job_id_key" // ALTER TABLE ONLY startup_script_logs ADD CONSTRAINT startup_script_logs_agent_id_job_id_key UNIQUE (agent_id, job_id); UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); From 66c8ec3e2949366de57e342f04668fdcdd34dd3f Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 22 Feb 2023 17:28:04 -0900 Subject: [PATCH 02/29] Add coderd endpoints for startup script logs --- coderd/apidoc/docs.go | 95 ++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 85 ++++++++++++++++++++++++++ coderd/coderd.go | 6 +- coderd/coderdtest/authorize.go | 1 + coderd/coderdtest/swaggerparser.go | 1 + coderd/workspaceagents.go | 42 +++++++++++++ coderd/workspacebuilds.go | 38 ++++++++++++ codersdk/agentsdk/agentsdk.go | 16 +++++ codersdk/workspacebuilds.go | 20 +++++++ docs/api/agents.md | 50 ++++++++++++++++ docs/api/schemas.md | 30 ++++++++++ site/src/api/typesGenerated.ts | 7 +++ 12 files changed, 390 insertions(+), 1 deletion(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d4e1236849534..4a0d250fe80fc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4193,6 +4193,45 @@ const docTemplate = `{ } } }, + "/workspaceagents/me/startup/logs": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Submit most recent workspace agent startup logs", + "operationId": "insert-update-startup-script-logs", + "parameters": [ + { + "description": "Startup logs", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.InsertOrUpdateStartupLogsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceagents/{workspaceagent}": { "get": { "security": [ @@ -4327,6 +4366,43 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Stream workspace agent startup logs", + "operationId": "stream-startup-script-logs", + "parameters": [ + { + "type": "string", + "description": "Workspace build ID", + "name": "workspacebuild", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.StartupScriptLog" + } + } + } + } + } + }, "/workspaceagents/{workspaceagent}/pty": { "get": { "security": [ @@ -5090,6 +5166,14 @@ const docTemplate = `{ } } }, + "agentsdk.InsertOrUpdateStartupLogsRequest": { + "type": "object", + "properties": { + "output": { + "type": "string" + } + } + }, "agentsdk.Metadata": { "type": "object", "properties": { @@ -7362,6 +7446,17 @@ const docTemplate = `{ } } }, + "codersdk.StartupScriptLog": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "output": { + "type": "string" + } + } + }, "codersdk.SwaggerConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 894a2d0b6cafd..761d3b07469c0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3683,6 +3683,39 @@ } } }, + "/workspaceagents/me/startup/logs": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Submit most recent workspace agent startup logs", + "operationId": "insert-update-startup-script-logs", + "parameters": [ + { + "description": "Startup logs", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.InsertOrUpdateStartupLogsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspaceagents/{workspaceagent}": { "get": { "security": [ @@ -3803,6 +3836,39 @@ } } }, + "/workspaceagents/{workspaceagent}/logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Stream workspace agent startup logs", + "operationId": "stream-startup-script-logs", + "parameters": [ + { + "type": "string", + "description": "Workspace build ID", + "name": "workspacebuild", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.StartupScriptLog" + } + } + } + } + } + }, "/workspaceagents/{workspaceagent}/pty": { "get": { "security": [ @@ -4487,6 +4553,14 @@ } } }, + "agentsdk.InsertOrUpdateStartupLogsRequest": { + "type": "object", + "properties": { + "output": { + "type": "string" + } + } + }, "agentsdk.Metadata": { "type": "object", "properties": { @@ -6608,6 +6682,17 @@ } } }, + "codersdk.StartupScriptLog": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "output": { + "type": "string" + } + } + }, "codersdk.SwaggerConfig": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 34f4f61f5fdf3..404d0b0fcd3ea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -572,7 +572,10 @@ func New(options *Options) *API { r.Route("/me", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/metadata", api.workspaceAgentMetadata) - r.Post("/startup", api.postWorkspaceAgentStartup) + r.Route("/startup", func(r chi.Router) { + r.Post("/", api.postWorkspaceAgentStartup) + r.Patch("/logs", api.insertOrUpdateStartupScriptLogs) + }) r.Post("/app-health", api.postWorkspaceAppHealth) r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/gitsshkey", api.agentGitSSHKey) @@ -630,6 +633,7 @@ func New(options *Options) *API { r.Get("/parameters", api.workspaceBuildParameters) r.Get("/resources", api.workspaceBuildResources) r.Get("/state", api.workspaceBuildState) + r.Get("/startup-script-logs", api.startupScriptLogs) }) r.Route("/authcheck", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 2b594bd1ee33a..61c5560a836dc 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -71,6 +71,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/startup": {NoAuthorize: true}, + "PATCH:/api/v2/workspaceagents/me/startup/logs": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/app-health": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/report-stats": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/report-lifecycle": {NoAuthorize: true}, diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 8abf78314930a..dda80d2f40800 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -345,6 +345,7 @@ func assertProduce(t *testing.T, comment SwaggerComment) { } else { if (comment.router == "/workspaceagents/me/app-health" && comment.method == "post") || (comment.router == "/workspaceagents/me/startup" && comment.method == "post") || + (comment.router == "/workspaceagents/me/startup/logs" && comment.method == "patch") || (comment.router == "/licenses/{id}" && comment.method == "delete") || (comment.router == "/debug/coordinator" && comment.method == "get") { return // Exception: HTTP 200 is returned without response entity diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3c6a4d432420e..8f539e92a53b2 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -208,6 +208,48 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques httpapi.Write(ctx, rw, http.StatusOK, nil) } +// @Summary Submit most recent workspace agent startup logs +// @ID insert-update-startup-script-logs +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Agents +// @Param request body agentsdk.InsertOrUpdateStartupLogsRequest true "Startup logs" +// @Success 200 +// @Router /workspaceagents/me/startup/logs [patch] +// @x-apidocgen {"skip": true} +func (api *API) insertOrUpdateStartupScriptLogs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgent(r) + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to upload startup logs", + Detail: err.Error(), + }) + return + } + + var req agentsdk.InsertOrUpdateStartupLogsRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if err := api.Database.InsertOrUpdateStartupScriptLog(ctx, database.InsertOrUpdateStartupScriptLogParams{ + AgentID: workspaceAgent.ID, + JobID: resource.JobID, + Output: req.Output, + }); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to upload startup logs", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, nil) +} + // workspaceAgentPTY spawns a PTY and pipes it over a WebSocket. // This is used for the web terminal. // diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 26176f5a0c93e..8b3cfa4a2d01c 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -874,6 +874,44 @@ func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) { api.provisionerJobLogs(rw, r, job) } +// @Summary Stream workspace agent startup logs +// @ID stream-startup-script-logs +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspacebuild path string true "Workspace build ID" +// @Success 200 {object} []codersdk.StartupScriptLog +// @Router /workspaceagents/{workspaceagent}/logs [get] +func (api *API) startupScriptLogs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceBuild := httpmw.WorkspaceBuildParam(r) + + // TODO: Wait until the logs are written or use SSE. + + dblogs, err := api.Database.GetStartupScriptLogsByJobID(ctx, workspaceBuild.JobID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.StartupScriptLog{}) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Error getting startup logs", + Detail: err.Error(), + }) + return + } + + logs := []codersdk.StartupScriptLog{} + for _, l := range dblogs { + logs = append(logs, codersdk.StartupScriptLog{ + AgentID: l.AgentID, + JobID: l.JobID, + Output: l.Output, + }) + } + httpapi.Write(ctx, rw, http.StatusOK, logs) +} + // @Summary Get provisioner state for workspace build // @ID get-provisioner-state-for-workspace-build // @Security CoderSessionToken diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index d0344eb7f07b8..048d9ee88e4f3 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -499,6 +499,22 @@ func (c *Client) PostStartup(ctx context.Context, req PostStartupRequest) error return nil } +type InsertOrUpdateStartupLogsRequest struct { + Output string +} + +func (c *Client) InsertOrUpdateStartupLogs(ctx context.Context, req InsertOrUpdateStartupLogsRequest) error { + res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/startup/logs", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return codersdk.ReadBodyAsError(res) + } + return nil +} + type GitAuthResponse struct { Username string `json:"username"` Password string `json:"password"` diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index ab43379c28da8..d72f20f6c8585 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -101,6 +101,12 @@ type WorkspaceBuildParameter struct { Value string `json:"value"` } +type StartupScriptLog struct { + AgentID uuid.UUID `json:"agent_id"` + JobID uuid.UUID `json:"job_id"` + Output string `json:"output"` +} + // WorkspaceBuild returns a single workspace build for a workspace. // If history is "", the latest version is returned. func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { @@ -139,6 +145,20 @@ func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, a return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), after) } +// StartupScriptLogs returns the logs from startup scripts. +func (c *Client) StartupScriptLogs(ctx context.Context, build uuid.UUID) ([]StartupScriptLog, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/startup-script-logs", build), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var log []StartupScriptLog + return log, json.NewDecoder(res.Body).Decode(&log) +} + // WorkspaceBuildState returns the provisioner state of the build. func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]byte, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil) diff --git a/docs/api/agents.md b/docs/api/agents.md index ffea8e40065f3..1f6c249e1ae30 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -683,6 +683,56 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/lis To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Stream workspace agent startup logs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/logs \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/{workspaceagent}/logs` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------------- | ---- | ------ | -------- | ------------------ | +| `workspacebuild` | path | string | true | Workspace build ID | + +### Example responses + +> 200 Response + +```json +[ + { + "name": "string", + "output": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.StartupScriptLog](schemas.md#codersdkstartupscriptlog) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» name` | string | false | | | +| `» output` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Open PTY to workspace agent ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 948a0e6304197..71171fe7bc5ec 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -94,6 +94,20 @@ | ---------------- | ------ | -------- | ------------ | ----------- | | `json_web_token` | string | true | | | +## agentsdk.InsertOrUpdateStartupLogsRequest + +```json +{ + "output": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------- | ------ | -------- | ------------ | ----------- | +| `output` | string | false | | | + ## agentsdk.Metadata ```json @@ -4120,6 +4134,22 @@ Parameter represents a set value for the scope. | `enabled` | boolean | false | | | | `message` | string | false | | | +## codersdk.StartupScriptLog + +```json +{ + "name": "string", + "output": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------- | ------ | -------- | ------------ | ----------- | +| `name` | string | false | | | +| `output` | string | false | | | + ## codersdk.SwaggerConfig ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c7159fb50a8f8..a84d3e36eae90 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -657,6 +657,13 @@ export interface ServiceBannerConfig { readonly background_color?: string } +// From codersdk/workspacebuilds.go +export interface StartupScriptLog { + readonly agent_id: string + readonly job_id: string + readonly output: string +} + // From codersdk/deployment.go export interface SwaggerConfig { readonly enable: DeploymentConfigField From 1cc3e9d674aa29a307b2340e04de4a1c7fef0ed3 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 22 Feb 2023 17:28:13 -0900 Subject: [PATCH 03/29] Push startup script logs from agent --- agent/agent.go | 19 +++++++++++++++++-- agent/agent_test.go | 21 ++++++++++++++++++--- go.mod | 1 + go.sum | 2 ++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index a23a781537c3a..ce4159bb4b65e 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -23,6 +23,7 @@ import ( "sync" "time" + "github.com/ammario/prefixsuffix" "github.com/armon/circbuf" "github.com/gliderlabs/ssh" "github.com/google/uuid" @@ -76,6 +77,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 + InsertOrUpdateStartupLogs(ctx context.Context, req agentsdk.InsertOrUpdateStartupLogsRequest) error } func New(options Options) io.Closer { @@ -617,13 +619,26 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error { } a.logger.Info(ctx, "running startup script", slog.F("script", script)) - writer, err := a.filesystem.OpenFile(filepath.Join(a.logDir, "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0o600) + + fileWriter, err := a.filesystem.OpenFile(filepath.Join(a.logDir, "coder-startup-script.log"), os.O_CREATE|os.O_RDWR, 0o600) if err != nil { return xerrors.Errorf("open startup script log file: %w", err) } defer func() { - _ = writer.Close() + _ = fileWriter.Close() + }() + + saver := &prefixsuffix.Saver{N: 512 << 10} + writer := io.MultiWriter(saver, fileWriter) + defer func() { + err := a.client.InsertOrUpdateStartupLogs(ctx, agentsdk.InsertOrUpdateStartupLogsRequest{ + Output: string(saver.Bytes()), + }) + if err != nil { + a.logger.Error(ctx, "upload startup logs", slog.Error(err)) + } }() + cmd, err := a.createCommand(ctx, script, nil) if err != nil { return xerrors.Errorf("create command: %w", err) diff --git a/agent/agent_test.go b/agent/agent_test.go index 815833cf22764..20238926d9762 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -667,9 +667,9 @@ func TestAgent_StartupScript(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("This test doesn't work on Windows for some reason...") } - content := "output" + content := "output\n" //nolint:dogsled - _, _, _, fs := setupAgent(t, agentsdk.Metadata{ + _, client, _, fs := setupAgent(t, agentsdk.Metadata{ StartupScript: "echo " + content, }, 0) var gotContent string @@ -694,7 +694,8 @@ func TestAgent_StartupScript(t *testing.T) { gotContent = string(content) return true }, testutil.WaitShort, testutil.IntervalMedium) - require.Equal(t, content, strings.TrimSpace(gotContent)) + require.Equal(t, content, gotContent) + require.Equal(t, content, client.getLogs()) } func TestAgent_Lifecycle(t *testing.T) { @@ -1229,6 +1230,7 @@ type client struct { mu sync.Mutex // Protects following. lifecycleStates []codersdk.WorkspaceAgentLifecycle startup agentsdk.PostStartupRequest + logs agentsdk.InsertOrUpdateStartupLogsRequest } func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) { @@ -1313,6 +1315,19 @@ func (c *client) PostStartup(_ context.Context, startup agentsdk.PostStartupRequ return nil } +func (c *client) getLogs() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.logs.Output +} + +func (c *client) InsertOrUpdateStartupLogs(_ context.Context, logs agentsdk.InsertOrUpdateStartupLogsRequest) error { + c.mu.Lock() + defer c.mu.Unlock() + c.logs = logs + return nil +} + // tempDirUnixSocket returns a temporary directory that can safely hold unix // sockets (probably). // diff --git a/go.mod b/go.mod index 9e1782a267b55..bdcd85468e998 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( cloud.google.com/go/compute/metadata v0.2.1 github.com/AlecAivazis/survey/v2 v2.3.5 github.com/adrg/xdg v0.4.0 + github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd github.com/andybalholm/brotli v1.0.4 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible diff --git a/go.sum b/go.sum index 2b346c6506e78..be02deace77ef 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1L github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd h1:WOzjyD34+0vVw3wzE7js8Yvzo08ljzvK1jG6wL8elVU= +github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd/go.mod h1:VM1c/0Tl3O26UkHMbU32VFqLwLvi2FA40b6s5vPOpoo= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= From 45d250fcad75efe2d0a737412068aca7a1a38282 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 22 Feb 2023 18:18:23 -0900 Subject: [PATCH 04/29] Pull startup script logs on frontend --- site/src/api/api.ts | 9 +++++++ .../Workspace/Workspace.stories.tsx | 10 ++++++++ site/src/components/Workspace/Workspace.tsx | 21 +++++++++++++++- .../src/pages/WorkspacePage/WorkspacePage.tsx | 24 ++++++++++++++++++- .../WorkspacePage/WorkspaceReadyPage.tsx | 3 +++ .../xServices/workspace/workspaceXService.ts | 1 + 6 files changed, 66 insertions(+), 2 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 37d89bf22288f..21cab9ae059f7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -773,6 +773,15 @@ export const getAgentListeningPorts = async ( return response.data } +export const getStartupScriptLogs = async ( + workspaceBuildId: string, +): Promise => { + const response = await axios.get( + `/api/v2/workspacebuilds/${workspaceBuildId}/startup-script-logs`, + ) + return response.data +} + export const getDeploymentConfig = async (): Promise => { const response = await axios.get(`/api/v2/config/deployment`) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 56a318cdac9ca..34c204d924dad 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -132,3 +132,13 @@ CancellationError.args = { }), }, } + +export const StartupLogsError = Template.bind({}) +StartupLogsError.args = { + StartupLogs: new Error("Unable to fetch startup logs"), +} + +export const StartupLogs = Template.bind({}) +StartupLogs.args = { + StartupLogs: [{ agent_id: "agent", job_id: "job", output: "startup logs" }], +} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index e3c75a1a5c6fd..f51587e7f73f2 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,6 +1,6 @@ import { makeStyles } from "@material-ui/core/styles" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" -import { FC } from "react" +import { FC, useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" import { BuildsTable } from "../BuildsTable/BuildsTable" @@ -24,6 +24,7 @@ import { } from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" import { AgentRow } from "components/Resources/AgentRow" import { Avatar } from "components/Avatar/Avatar" +import { CodeBlock } from "components/CodeBlock/CodeBlock" export enum WorkspaceErrors { GET_RESOURCES_ERROR = "getResourcesError", @@ -60,6 +61,7 @@ export interface WorkspaceProps { template?: TypesGen.Template templateParameters?: TypesGen.TemplateVersionParameter[] quota_budget?: number + startupScriptLogs?: TypesGen.StartupScriptLog[] | Error | unknown } /** @@ -87,6 +89,7 @@ export const Workspace: FC> = ({ template, templateParameters, quota_budget, + startupScriptLogs, }) => { const { t } = useTranslation("workspacePage") const styles = useStyles() @@ -233,6 +236,19 @@ export const Workspace: FC> = ({ /> )} + {typeof startupScriptLogs !== "undefined" && + (Array.isArray(startupScriptLogs) ? ( + startupScriptLogs.map((log) => ( + + )) + ) : ( + + ))} + {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( { timelineContents: { margin: 0, }, + logs: { + border: `1px solid ${theme.palette.divider}`, + }, } }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 1fae46c62c795..90f429cfb75c1 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,8 +1,9 @@ import { makeStyles } from "@material-ui/core/styles" import { useMachine } from "@xstate/react" +import * as API from "api/api" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" -import { FC, useEffect } from "react" +import { FC, useEffect, useState } from "react" import { useParams } from "react-router-dom" import { Loader } from "components/Loader/Loader" import { firstOrItem } from "util/array" @@ -27,6 +28,10 @@ export const WorkspacePage: FC = () => { const { getQuotaError } = quotaState.context const styles = useStyles() + const [startupScriptLogs, setStartupScriptLogs] = useState< + Record | Error | undefined | unknown + >(undefined) + /** * Get workspace, template, and organization on mount and whenever workspaceId changes. * workspaceSend should not change. @@ -41,6 +46,22 @@ export const WorkspacePage: FC = () => { username && quotaSend({ type: "GET_QUOTA", username }) }, [username, quotaSend]) + // Get startup logs once we have agents or when the agents change. + // TODO: Should use xstate? Or that new thing? + // TODO: Does not stream yet. + // TODO: Should maybe add to the existing SSE endpoint instead? + useEffect(() => { + if (workspace?.latest_build) { + API.getStartupScriptLogs(workspace.latest_build.id) + .then((logs) => { + setStartupScriptLogs(logs) + }) + .catch((error) => { + setStartupScriptLogs(error) + }) + } + }, [workspace]) + return ( @@ -76,6 +97,7 @@ export const WorkspacePage: FC = () => { workspaceState={workspaceState} quotaState={quotaState} workspaceSend={workspaceSend} + startupScriptLogs={startupScriptLogs} /> diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index d346b48caa3b9..10f483b19a3a4 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -30,12 +30,14 @@ interface WorkspaceReadyPageProps { workspaceState: StateFrom quotaState: StateFrom workspaceSend: (event: WorkspaceEvent) => void + startupScriptLogs?: Record | Error } export const WorkspaceReadyPage = ({ workspaceState, quotaState, workspaceSend, + startupScriptLogs, }: WorkspaceReadyPageProps): JSX.Element => { const [_, bannerSend] = useActor( workspaceState.children["scheduleBannerMachine"], @@ -129,6 +131,7 @@ export const WorkspaceReadyPage = ({ template={template} templateParameters={templateParameters} quota_budget={quotaState.context.quota?.budget} + startupScriptLogs={startupScriptLogs} /> Date: Fri, 10 Mar 2023 15:43:07 +0000 Subject: [PATCH 05/29] Rename queries --- agent/agent.go | 2 +- coderd/coderd.go | 2 +- coderd/database/dbauthz/querier.go | 29 +--- coderd/database/dbauthz/querier_test.go | 11 +- coderd/database/dbauthz/system.go | 4 + coderd/database/dbfake/databasefake.go | 60 +++++--- coderd/database/dump.sql | 30 ++-- .../000100_add_startup_logs.down.sql | 1 - .../migrations/000100_add_startup_logs.up.sql | 6 - .../000109_add_startup_logs.down.sql | 2 + .../migrations/000109_add_startup_logs.up.sql | 9 ++ coderd/database/models.go | 13 +- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 145 +++++++++++------- coderd/database/queries/startupscriptlogs.sql | 18 --- coderd/database/queries/workspaceagents.sql | 20 +++ coderd/database/unique_constraint.go | 2 +- coderd/workspaceagents.go | 29 ++-- codersdk/agentsdk/agentsdk.go | 7 +- 19 files changed, 221 insertions(+), 173 deletions(-) delete mode 100644 coderd/database/migrations/000100_add_startup_logs.down.sql delete mode 100644 coderd/database/migrations/000100_add_startup_logs.up.sql create mode 100644 coderd/database/migrations/000109_add_startup_logs.down.sql create mode 100644 coderd/database/migrations/000109_add_startup_logs.up.sql delete mode 100644 coderd/database/queries/startupscriptlogs.sql diff --git a/agent/agent.go b/agent/agent.go index 25f34356b6613..e4a31eac78454 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -88,7 +88,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 - InsertOrUpdateStartupLogs(ctx context.Context, req agentsdk.InsertOrUpdateStartupLogsRequest) error + AppendStartupLogs(ctx context.Context, req []agentsdk.StartupLog) error } func New(options Options) io.Closer { diff --git a/coderd/coderd.go b/coderd/coderd.go index 1727713c3f47f..acb87330ec191 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -589,7 +589,7 @@ func New(options *Options) *API { r.Get("/metadata", api.workspaceAgentMetadata) r.Route("/startup", func(r chi.Router) { r.Post("/", api.postWorkspaceAgentStartup) - r.Patch("/logs", api.insertOrUpdateStartupScriptLogs) + r.Patch("/logs", api.patchWorkspaceAgentStartupLogs) }) r.Post("/app-health", api.postWorkspaceAppHealth) r.Get("/gitauth", api.workspaceAgentsGitAuth) diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index df941f43ad953..2e32771337090 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -272,6 +272,14 @@ func (q *querier) GetProvisionerLogsByIDBetween(ctx context.Context, arg databas return q.db.GetProvisionerLogsByIDBetween(ctx, arg) } +func (q *querier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsBetweenParams) ([]database.WorkspaceAgentStartupLog, error) { + _, err := q.GetWorkspaceAgentByID(ctx, arg.AgentID) + if err != nil { + return nil, err + } + return q.db.GetWorkspaceAgentStartupLogsBetween(ctx, arg) +} + func (q *querier) GetLicenses(ctx context.Context) ([]database.License, error) { fetch := func(ctx context.Context, _ interface{}) ([]database.License, error) { return q.db.GetLicenses(ctx) @@ -1247,27 +1255,6 @@ func (q *querier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg datab return q.db.UpdateWorkspaceAgentStartupByID(ctx, arg) } -func (q *querier) GetStartupScriptLogsByJobID(ctx context.Context, jobID uuid.UUID) ([]database.StartupScriptLog, error) { - build, err := q.db.GetWorkspaceBuildByJobID(ctx, jobID) - if err != nil { - return nil, err - } - // Authorized fetch - _, err = q.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return nil, err - } - return q.db.GetStartupScriptLogsByJobID(ctx, jobID) -} - -func (q *querier) InsertOrUpdateStartupScriptLog(ctx context.Context, arg database.InsertOrUpdateStartupScriptLogParams) error { - // Authorized fetch - if _, err := q.GetWorkspaceByAgentID(ctx, arg.AgentID); err != nil { - return err - } - return q.db.InsertOrUpdateStartupScriptLog(ctx, arg) -} - func (q *querier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg database.GetWorkspaceAppByAgentIDAndSlugParams) (database.WorkspaceApp, error) { // If we can fetch the workspace, we can fetch the apps. Use the authorized call. if _, err := q.GetWorkspaceByAgentID(ctx, arg.AgentID); err != nil { diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index 1a5e316a594a8..533fbcefa8d96 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -1004,20 +1004,19 @@ func (s *MethodTestSuite) TestWorkspace() { ID: agt.ID, }).Asserts(ws, rbac.ActionUpdate).Returns() })) - s.Run("GetStartupScriptLogsByJobID", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetWorkspaceAgentStartupLogsBetween", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) - check.Args(build.JobID).Asserts(ws, rbac.ActionRead).Returns([]database.StartupScriptLog{}) + check.Args(build.JobID).Asserts(ws, rbac.ActionRead).Returns([]database.WorkspaceAgentStartupLog{}) })) - s.Run("InsertOrUpdateStartupScriptLog", s.Subtest(func(db database.Store, check *expects) { + s.Run("InsertWorkspaceAgentStartupLogs", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) - check.Args(database.InsertOrUpdateStartupScriptLogParams{ + check.Args(database.InsertWorkspaceAgentStartupLogsParams{ AgentID: agt.ID, - JobID: build.JobID, - Output: "test", + Output: []string{"test"}, }).Asserts(ws, rbac.ActionUpdate).Returns() })) s.Run("GetWorkspaceAppByAgentIDAndSlug", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index e83b0b0771f9d..dccb9b4c18c0c 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -251,6 +251,10 @@ func (q *querier) InsertProvisionerJobLogs(ctx context.Context, arg database.Ins return q.db.InsertProvisionerJobLogs(ctx, arg) } +func (q *querier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg database.InsertWorkspaceAgentStartupLogsParams) ([]database.WorkspaceAgentStartupLog, error) { + return q.db.InsertWorkspaceAgentStartupLogs(ctx, arg) +} + func (q *querier) InsertProvisionerDaemon(ctx context.Context, arg database.InsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) { return q.db.InsertProvisionerDaemon(ctx, arg) } diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index a1ac8b6be5718..94364fbfa37d7 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -52,7 +52,6 @@ func New() database.Store { parameterSchemas: make([]database.ParameterSchema, 0), parameterValues: make([]database.ParameterValue, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), - startupScriptLogs: make([]database.StartupScriptLog, 0), workspaceAgents: make([]database.WorkspaceAgent, 0), provisionerJobLogs: make([]database.ProvisionerJobLog, 0), workspaceResources: make([]database.WorkspaceResource, 0), @@ -61,6 +60,7 @@ func New() database.Store { templateVersions: make([]database.TemplateVersion, 0), templates: make([]database.Template, 0), workspaceAgentStats: make([]database.WorkspaceAgentStat, 0), + workspaceAgentLogs: make([]database.WorkspaceAgentStartupLog, 0), workspaceBuilds: make([]database.WorkspaceBuild, 0), workspaceApps: make([]database.WorkspaceApp, 0), workspaces: make([]database.Workspace, 0), @@ -119,12 +119,12 @@ type data struct { provisionerJobLogs []database.ProvisionerJobLog provisionerJobs []database.ProvisionerJob replicas []database.Replica - startupScriptLogs []database.StartupScriptLog templateVersions []database.TemplateVersion templateVersionParameters []database.TemplateVersionParameter templateVersionVariables []database.TemplateVersionVariable templates []database.Template workspaceAgents []database.WorkspaceAgent + workspaceAgentLogs []database.WorkspaceAgentStartupLog workspaceApps []database.WorkspaceApp workspaceBuilds []database.WorkspaceBuild workspaceBuildParameters []database.WorkspaceBuildParameter @@ -3514,42 +3514,54 @@ func (q *fakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } -func (q *fakeQuerier) GetStartupScriptLogsByJobID(_ context.Context, jobID uuid.UUID) ([]database.StartupScriptLog, error) { +func (q *fakeQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsBetweenParams) ([]database.WorkspaceAgentStartupLog, error) { + if err := validateDatabaseType(arg); err != nil { + return nil, err + } + q.mutex.Lock() defer q.mutex.Unlock() - logs := []database.StartupScriptLog{} - for _, log := range q.startupScriptLogs { - if log.JobID == jobID { - logs = append(logs, log) + logs := []database.WorkspaceAgentStartupLog{} + for _, log := range q.workspaceAgentLogs { + if log.AgentID != arg.AgentID { + continue + } + if arg.CreatedBefore != 0 && log.ID > arg.CreatedBefore { + continue } + if arg.CreatedAfter != 0 && log.ID < arg.CreatedAfter { + continue + } + logs = append(logs, log) } - return logs, sql.ErrNoRows + return logs, nil } -func (q *fakeQuerier) InsertOrUpdateStartupScriptLog(_ context.Context, arg database.InsertOrUpdateStartupScriptLogParams) error { +func (q *fakeQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg database.InsertWorkspaceAgentStartupLogsParams) ([]database.WorkspaceAgentStartupLog, error) { if err := validateDatabaseType(arg); err != nil { - return err + return nil, err } q.mutex.Lock() defer q.mutex.Unlock() - for index, log := range q.startupScriptLogs { - if log.JobID != arg.JobID { - continue - } - - log.Output = arg.Output - q.startupScriptLogs[index] = log - return nil + logs := []database.WorkspaceAgentStartupLog{} + id := int64(1) + if len(q.workspaceAgentLogs) > 0 { + id = q.workspaceAgentLogs[len(q.workspaceAgentLogs)-1].ID } - q.startupScriptLogs = append(q.startupScriptLogs, database.StartupScriptLog{ - AgentID: arg.AgentID, - JobID: arg.JobID, - Output: arg.Output, - }) - return nil + for index, output := range arg.Output { + id++ + logs = append(logs, database.WorkspaceAgentStartupLog{ + ID: id, + AgentID: arg.AgentID, + CreatedAt: arg.CreatedAt[index], + Output: output, + }) + } + q.workspaceAgentLogs = append(q.workspaceAgentLogs, logs...) + return logs, nil } func (q *fakeQuerier) UpdateProvisionerJobByID(_ context.Context, arg database.UpdateProvisionerJobByIDParams) error { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c466865f6a67b..b7fc627b91c6b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -338,12 +338,6 @@ CREATE TABLE site_configs ( value character varying(8192) NOT NULL ); -CREATE TABLE startup_script_logs ( - agent_id uuid NOT NULL, - job_id uuid NOT NULL, - output text NOT NULL -); - CREATE TABLE template_version_parameters ( template_version_id uuid NOT NULL, name text NOT NULL, @@ -478,6 +472,13 @@ CREATE TABLE users ( last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL ); +CREATE TABLE workspace_agent_startup_logs ( + agent_id uuid NOT NULL, + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + output text NOT NULL +); + CREATE TABLE workspace_agent_stats ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -713,9 +714,6 @@ ALTER TABLE ONLY provisioner_jobs ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); -ALTER TABLE ONLY startup_script_logs - ADD CONSTRAINT startup_script_logs_agent_id_job_id_key UNIQUE (agent_id, job_id); - ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); @@ -737,6 +735,9 @@ ALTER TABLE ONLY user_links ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY workspace_agent_startup_logs + ADD CONSTRAINT workspace_agent_startup_logs_agent_id_id_key UNIQUE (agent_id, id); + ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); @@ -808,6 +809,8 @@ CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WH CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); +CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_startup_logs USING btree (agent_id, id); + CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (auth_token); CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id); @@ -846,12 +849,6 @@ ALTER TABLE ONLY provisioner_job_logs ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; -ALTER TABLE ONLY startup_script_logs - ADD CONSTRAINT startup_script_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - -ALTER TABLE ONLY startup_script_logs - ADD CONSTRAINT startup_script_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; @@ -876,6 +873,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_startup_logs + ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000100_add_startup_logs.down.sql b/coderd/database/migrations/000100_add_startup_logs.down.sql deleted file mode 100644 index 07f83067d4fe6..0000000000000 --- a/coderd/database/migrations/000100_add_startup_logs.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE startup_script_logs diff --git a/coderd/database/migrations/000100_add_startup_logs.up.sql b/coderd/database/migrations/000100_add_startup_logs.up.sql deleted file mode 100644 index 86726c6012c66..0000000000000 --- a/coderd/database/migrations/000100_add_startup_logs.up.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS startup_script_logs ( - agent_id uuid NOT NULL REFERENCES workspace_agents (id) ON DELETE CASCADE, - job_id uuid NOT NULL REFERENCES provisioner_jobs (id) ON DELETE CASCADE, - output text NOT NULL, - UNIQUE(agent_id, job_id) -); diff --git a/coderd/database/migrations/000109_add_startup_logs.down.sql b/coderd/database/migrations/000109_add_startup_logs.down.sql new file mode 100644 index 0000000000000..0109865a8c2e6 --- /dev/null +++ b/coderd/database/migrations/000109_add_startup_logs.down.sql @@ -0,0 +1,2 @@ +DROP TABLE workspace_agent_startup_logs; +DROP INDEX workspace_agent_startup_logs_id_agent_id_idx; diff --git a/coderd/database/migrations/000109_add_startup_logs.up.sql b/coderd/database/migrations/000109_add_startup_logs.up.sql new file mode 100644 index 0000000000000..a0d6e8bfe7b2a --- /dev/null +++ b/coderd/database/migrations/000109_add_startup_logs.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS workspace_agent_startup_logs ( + agent_id uuid NOT NULL REFERENCES workspace_agents (id) ON DELETE CASCADE, + id bigint NOT NULL, + created_at timestamptz NOT NULL, + output varchar(1024) NOT NULL, + UNIQUE(agent_id, id) +); + +CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_startup_logs USING btree (agent_id, id ASC); diff --git a/coderd/database/models.go b/coderd/database/models.go index 54fdc5e5b05fd..8907823c6941b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1405,12 +1405,6 @@ type SiteConfig struct { Value string `db:"value" json:"value"` } -type StartupScriptLog struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - JobID uuid.UUID `db:"job_id" json:"job_id"` - Output string `db:"output" json:"output"` -} - type Template struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` @@ -1575,6 +1569,13 @@ type WorkspaceAgent struct { ShutdownScriptTimeoutSeconds int32 `db:"shutdown_script_timeout_seconds" json:"shutdown_script_timeout_seconds"` } +type WorkspaceAgentStartupLog struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + ID int64 `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Output string `db:"output" json:"output"` +} + type WorkspaceAgentStat struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4febdc1d6e527..d807e13605a27 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -92,7 +92,6 @@ type sqlcQuerier interface { GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) GetServiceBanner(ctx context.Context) (string, error) - GetStartupScriptLogsByJobID(ctx context.Context, jobID uuid.UUID) ([]StartupScriptLog, error) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) @@ -122,6 +121,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) + GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg GetWorkspaceAgentStartupLogsBetweenParams) ([]WorkspaceAgentStartupLog, error) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) @@ -163,7 +163,6 @@ type sqlcQuerier interface { InsertOrUpdateLastUpdateCheck(ctx context.Context, value string) error InsertOrUpdateLogoURL(ctx context.Context, value string) error InsertOrUpdateServiceBanner(ctx context.Context, value string) error - InsertOrUpdateStartupScriptLog(ctx context.Context, arg InsertOrUpdateStartupScriptLogParams) error InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertParameterSchema(ctx context.Context, arg InsertParameterSchemaParams) (ParameterSchema, error) @@ -182,6 +181,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) + InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) (WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b5c595b7d4843..d5a11dba7cb45 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3066,61 +3066,6 @@ func (q *sqlQuerier) InsertOrUpdateServiceBanner(ctx context.Context, value stri return err } -const getStartupScriptLogsByJobID = `-- name: GetStartupScriptLogsByJobID :many -SELECT - agent_id, job_id, output -FROM - startup_script_logs -WHERE - job_id = $1 -` - -func (q *sqlQuerier) GetStartupScriptLogsByJobID(ctx context.Context, jobID uuid.UUID) ([]StartupScriptLog, error) { - rows, err := q.db.QueryContext(ctx, getStartupScriptLogsByJobID, jobID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []StartupScriptLog - for rows.Next() { - var i StartupScriptLog - if err := rows.Scan(&i.AgentID, &i.JobID, &i.Output); 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 insertOrUpdateStartupScriptLog = `-- name: InsertOrUpdateStartupScriptLog :exec -INSERT INTO - startup_script_logs (agent_id, job_id, output) -VALUES ($1, $2, $3) -ON CONFLICT (agent_id, job_id) DO UPDATE - SET - output = $3 - WHERE - startup_script_logs.agent_id = $1 - AND startup_script_logs.job_id = $2 -` - -type InsertOrUpdateStartupScriptLogParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - JobID uuid.UUID `db:"job_id" json:"job_id"` - Output string `db:"output" json:"output"` -} - -func (q *sqlQuerier) InsertOrUpdateStartupScriptLog(ctx context.Context, arg InsertOrUpdateStartupScriptLogParams) error { - _, err := q.db.ExecContext(ctx, insertOrUpdateStartupScriptLog, arg.AgentID, arg.JobID, arg.Output) - return err -} - const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one WITH build_times AS ( SELECT @@ -5240,6 +5185,53 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst return i, err } +const getWorkspaceAgentStartupLogsBetween = `-- name: GetWorkspaceAgentStartupLogsBetween :many +SELECT + agent_id, id, created_at, output +FROM + workspace_agent_startup_logs +WHERE + agent_id = $1 + AND ( + id > $2 + OR id < $3 + ) ORDER BY id ASC +` + +type GetWorkspaceAgentStartupLogsBetweenParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAfter int64 `db:"created_after" json:"created_after"` + CreatedBefore int64 `db:"created_before" json:"created_before"` +} + +func (q *sqlQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg GetWorkspaceAgentStartupLogsBetweenParams) ([]WorkspaceAgentStartupLog, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentStartupLogsBetween, arg.AgentID, arg.CreatedAfter, arg.CreatedBefore) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentStartupLog + for rows.Next() { + var i WorkspaceAgentStartupLog + if err := rows.Scan( + &i.AgentID, + &i.ID, + &i.CreatedAt, + &i.Output, + ); 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 getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds @@ -5468,6 +5460,49 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa return i, err } +const insertWorkspaceAgentStartupLogs = `-- name: InsertWorkspaceAgentStartupLogs :many +INSERT INTO + workspace_agent_startup_logs +SELECT + $1 :: uuid AS agent_id, + unnest($2 :: timestamptz [ ]) AS created_at, + unnest($3 :: VARCHAR(1024) [ ]) AS output RETURNING agent_id, id, created_at, output +` + +type InsertWorkspaceAgentStartupLogsParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + Output []string `db:"output" json:"output"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error) { + rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentStartupLogs, arg.AgentID, pq.Array(arg.CreatedAt), pq.Array(arg.Output)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentStartupLog + for rows.Next() { + var i WorkspaceAgentStartupLog + if err := rows.Scan( + &i.AgentID, + &i.ID, + &i.CreatedAt, + &i.Output, + ); 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 updateWorkspaceAgentConnectionByID = `-- name: UpdateWorkspaceAgentConnectionByID :exec UPDATE workspace_agents diff --git a/coderd/database/queries/startupscriptlogs.sql b/coderd/database/queries/startupscriptlogs.sql deleted file mode 100644 index 8796b48965248..0000000000000 --- a/coderd/database/queries/startupscriptlogs.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: GetStartupScriptLogsByJobID :many -SELECT - * -FROM - startup_script_logs -WHERE - job_id = $1; - --- name: InsertOrUpdateStartupScriptLog :exec -INSERT INTO - startup_script_logs (agent_id, job_id, output) -VALUES ($1, $2, $3) -ON CONFLICT (agent_id, job_id) DO UPDATE - SET - output = $3 - WHERE - startup_script_logs.agent_id = $1 - AND startup_script_logs.job_id = $2; diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 2bc30faf21095..bcf1d360d0124 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -93,3 +93,23 @@ SET lifecycle_state = $2 WHERE id = $1; + +-- name: GetWorkspaceAgentStartupLogsBetween :many +SELECT + * +FROM + workspace_agent_startup_logs +WHERE + agent_id = $1 + AND ( + id > @created_after + OR id < @created_before + ) ORDER BY id ASC; + +-- name: InsertWorkspaceAgentStartupLogs :many +INSERT INTO + workspace_agent_startup_logs +SELECT + @agent_id :: uuid AS agent_id, + unnest(@created_at :: timestamptz [ ]) AS created_at, + unnest(@output :: VARCHAR(1024) [ ]) AS output RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index b674244abeef5..223c132dc846b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -15,10 +15,10 @@ const ( UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); UniqueProvisionerDaemonsNameKey UniqueConstraint = "provisioner_daemons_name_key" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_name_key UNIQUE (name); UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); - UniqueStartupScriptLogsAgentIDJobIDKey UniqueConstraint = "startup_script_logs_agent_id_job_id_key" // ALTER TABLE ONLY startup_script_logs ADD CONSTRAINT startup_script_logs_agent_id_job_id_key UNIQUE (agent_id, job_id); UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); + UniqueWorkspaceAgentStartupLogsAgentIDIDKey UniqueConstraint = "workspace_agent_startup_logs_agent_id_id_key" // ALTER TABLE ONLY workspace_agent_startup_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_id_key UNIQUE (agent_id, id); UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 5fbd19ef97c58..39b23372b86be 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -229,28 +229,27 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques // @Success 200 // @Router /workspaceagents/me/startup/logs [patch] // @x-apidocgen {"skip": true} -func (api *API) insertOrUpdateStartupScriptLogs(rw http.ResponseWriter, r *http.Request) { +func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to upload startup logs", - Detail: err.Error(), - }) - return - } - var req agentsdk.InsertOrUpdateStartupLogsRequest + var req []agentsdk.StartupLog if !httpapi.Read(ctx, rw, r, &req) { return } - if err := api.Database.InsertOrUpdateStartupScriptLog(ctx, database.InsertOrUpdateStartupScriptLogParams{ - AgentID: workspaceAgent.ID, - JobID: resource.JobID, - Output: req.Output, - }); err != nil { + createdAt := make([]time.Time, 0) + output := make([]string, 0) + for _, log := range req { + createdAt = append(createdAt, log.CreatedAt) + output = append(output, log.Output) + } + _, err := api.Database.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: workspaceAgent.ID, + CreatedAt: createdAt, + Output: output, + }) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to upload startup logs", Detail: err.Error(), diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 90a71bf813804..452fd65d43fbe 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -520,7 +520,12 @@ type InsertOrUpdateStartupLogsRequest struct { Output string } -func (c *Client) InsertOrUpdateStartupLogs(ctx context.Context, req InsertOrUpdateStartupLogsRequest) error { +type StartupLog struct { + CreatedAt time.Time `json:"created_at"` + Output string `json:"output"` +} + +func (c *Client) AppendStartupLogs(ctx context.Context, req []StartupLog) error { res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/startup/logs", req) if err != nil { return err From b86c400e646fe5cbba2d8d8fa46fb1d5f9115c87 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 11 Mar 2023 13:27:08 -0600 Subject: [PATCH 06/29] Add constraint --- agent/agent.go | 12 ++--- coderd/database/dump.sql | 23 ++++++-- coderd/database/errors.go | 9 ++++ .../migrations/000109_add_startup_logs.up.sql | 8 +-- coderd/database/models.go | 4 +- coderd/database/querier_test.go | 37 +++++++++++++ coderd/database/queries.sql.go | 53 ++++++++++++------- coderd/database/queries/workspaceagents.sql | 15 ++++-- coderd/database/unique_constraint.go | 1 - coderd/workspacebuilds.go | 6 +-- 10 files changed, 125 insertions(+), 43 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index e4a31eac78454..9391678fbaa59 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -652,12 +652,12 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { saver := &prefixsuffix.Saver{N: 512 << 10} writer := io.MultiWriter(saver, fileWriter) defer func() { - err := a.client.InsertOrUpdateStartupLogs(ctx, agentsdk.InsertOrUpdateStartupLogsRequest{ - Output: string(saver.Bytes()), - }) - if err != nil { - a.logger.Error(ctx, "upload startup logs", slog.Error(err)) - } + // err := a.client.AppendStartupLogs(ctx, agentsdk.InsertOrUpdateStartupLogsRequest{ + // Output: string(saver.Bytes()), + // }) + // if err != nil { + // a.logger.Error(ctx, "upload startup logs", slog.Error(err)) + // } }() cmd, err := a.createCommand(ctx, script, nil) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b7fc627b91c6b..9bcb6560486ab 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -474,11 +474,20 @@ CREATE TABLE users ( CREATE TABLE workspace_agent_startup_logs ( agent_id uuid NOT NULL, - id bigint NOT NULL, created_at timestamp with time zone NOT NULL, - output text NOT NULL + output character varying(1024) NOT NULL, + id bigint NOT NULL ); +CREATE SEQUENCE workspace_agent_startup_logs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE workspace_agent_startup_logs_id_seq OWNED BY workspace_agent_startup_logs.id; + CREATE TABLE workspace_agent_stats ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -527,7 +536,9 @@ CREATE TABLE workspace_agents ( startup_script_timeout_seconds integer DEFAULT 0 NOT NULL, expanded_directory character varying(4096) DEFAULT ''::character varying NOT NULL, shutdown_script character varying(65534), - shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL + shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL, + startup_logs_length integer DEFAULT 0 NOT NULL, + CONSTRAINT max_startup_logs_length CHECK ((startup_logs_length <= 1048576)) ); COMMENT ON COLUMN workspace_agents.version IS 'Version tracks the version of the currently running workspace agent. Workspace agents register their version upon start.'; @@ -550,6 +561,8 @@ COMMENT ON COLUMN workspace_agents.shutdown_script IS 'Script that is executed b COMMENT ON COLUMN workspace_agents.shutdown_script_timeout_seconds IS 'The number of seconds to wait for the shutdown script to complete. If the script does not complete within this time, the agent lifecycle will be marked as shutdown_timeout.'; +COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -643,6 +656,8 @@ ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq': ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); +ALTER TABLE ONLY workspace_agent_startup_logs ALTER COLUMN id SET DEFAULT nextval('workspace_agent_startup_logs_id_seq'::regclass); + ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval('workspace_resource_metadata_id_seq'::regclass); ALTER TABLE ONLY workspace_agent_stats @@ -736,7 +751,7 @@ ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); ALTER TABLE ONLY workspace_agent_startup_logs - ADD CONSTRAINT workspace_agent_startup_logs_agent_id_id_key UNIQUE (agent_id, id); + ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); diff --git a/coderd/database/errors.go b/coderd/database/errors.go index 9a7c04cd5f528..9cf06b14f8eb5 100644 --- a/coderd/database/errors.go +++ b/coderd/database/errors.go @@ -37,3 +37,12 @@ func IsQueryCanceledError(err error) bool { return false } + +func IsStartupLogsLimitError(err error) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + return pqErr.Constraint == "max_startup_logs_length" && pqErr.Table == "workspace_agents" + } + + return false +} diff --git a/coderd/database/migrations/000109_add_startup_logs.up.sql b/coderd/database/migrations/000109_add_startup_logs.up.sql index a0d6e8bfe7b2a..03aedb9d6c425 100644 --- a/coderd/database/migrations/000109_add_startup_logs.up.sql +++ b/coderd/database/migrations/000109_add_startup_logs.up.sql @@ -1,9 +1,11 @@ CREATE TABLE IF NOT EXISTS workspace_agent_startup_logs ( agent_id uuid NOT NULL REFERENCES workspace_agents (id) ON DELETE CASCADE, - id bigint NOT NULL, created_at timestamptz NOT NULL, output varchar(1024) NOT NULL, - UNIQUE(agent_id, id) + id BIGSERIAL PRIMARY KEY ); - CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_startup_logs USING btree (agent_id, id ASC); + +ALTER TABLE workspace_agents ADD COLUMN startup_logs_length integer NOT NULL DEFAULT 0 CONSTRAINT max_startup_logs_length CHECK (startup_logs_length <= 1048576); +COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; + diff --git a/coderd/database/models.go b/coderd/database/models.go index 8907823c6941b..afa33006b35bd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1567,13 +1567,15 @@ type WorkspaceAgent struct { ShutdownScript sql.NullString `db:"shutdown_script" json:"shutdown_script"` // The number of seconds to wait for the shutdown script to complete. If the script does not complete within this time, the agent lifecycle will be marked as shutdown_timeout. ShutdownScriptTimeoutSeconds int32 `db:"shutdown_script_timeout_seconds" json:"shutdown_script_timeout_seconds"` + // Total length of startup logs + StartupLogsLength int32 `db:"startup_logs_length" json:"startup_logs_length"` } type WorkspaceAgentStartupLog struct { AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - ID int64 `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` Output string `db:"output" json:"output"` + ID int64 `db:"id" json:"id"` } type WorkspaceAgentStat struct { diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5e8def562c16d..4df7bd15c90c6 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -86,3 +86,40 @@ func TestGetDeploymentWorkspaceAgentStats(t *testing.T) { require.Equal(t, int64(1), stats.SessionCountVSCode) }) } + +func TestInsertWorkspaceAgentStartupLogs(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + sqlDB := testSQLDB(t) + ctx := context.Background() + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + org := dbgen.Organization(t, db, database.Organization{}) + job := dbgen.ProvisionerJob(t, db, database.ProvisionerJob{ + OrganizationID: org.ID, + }) + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + logs, err := db.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: agent.ID, + CreatedAt: []time.Time{database.Now()}, + Output: []string{"first"}, + OutputLength: 1 << 20, + }) + require.NoError(t, err) + require.Equal(t, int64(1), logs[0].ID) + + _, err = db.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: agent.ID, + CreatedAt: []time.Time{database.Now()}, + Output: []string{"second"}, + }) + require.True(t, database.IsStartupLogsLimitError(err)) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d5a11dba7cb45..262bdff25adae 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5048,7 +5048,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length FROM workspace_agents WHERE @@ -5089,13 +5089,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, ) return i, err } const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length FROM workspace_agents WHERE @@ -5134,13 +5135,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, ) return i, err } const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length FROM workspace_agents WHERE @@ -5181,13 +5183,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, ) return i, err } const getWorkspaceAgentStartupLogsBetween = `-- name: GetWorkspaceAgentStartupLogsBetween :many SELECT - agent_id, id, created_at, output + agent_id, created_at, output, id FROM workspace_agent_startup_logs WHERE @@ -5215,9 +5218,9 @@ func (q *sqlQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, ar var i WorkspaceAgentStartupLog if err := rows.Scan( &i.AgentID, - &i.ID, &i.CreatedAt, &i.Output, + &i.ID, ); err != nil { return nil, err } @@ -5234,7 +5237,7 @@ func (q *sqlQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, ar const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length FROM workspace_agents WHERE @@ -5279,6 +5282,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, ); err != nil { return nil, err } @@ -5294,7 +5298,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] } const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many -SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds FROM workspace_agents WHERE created_at > $1 +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length FROM workspace_agents WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { @@ -5335,6 +5339,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, ); err != nil { return nil, err } @@ -5375,7 +5380,7 @@ INSERT INTO shutdown_script_timeout_seconds ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length ` type InsertWorkspaceAgentParams struct { @@ -5456,27 +5461,39 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ExpandedDirectory, &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, + &i.StartupLogsLength, ) return i, err } const insertWorkspaceAgentStartupLogs = `-- name: InsertWorkspaceAgentStartupLogs :many +WITH new_length AS ( + UPDATE workspace_agents SET + startup_logs_length = startup_logs_length + $4 WHERE workspace_agents.id = $1 +) INSERT INTO - workspace_agent_startup_logs -SELECT - $1 :: uuid AS agent_id, - unnest($2 :: timestamptz [ ]) AS created_at, - unnest($3 :: VARCHAR(1024) [ ]) AS output RETURNING agent_id, id, created_at, output + workspace_agent_startup_logs + SELECT + $1 :: uuid AS agent_id, + unnest($2 :: timestamptz [ ]) AS created_at, + unnest($3 :: VARCHAR(1024) [ ]) AS output + RETURNING workspace_agent_startup_logs.agent_id, workspace_agent_startup_logs.created_at, workspace_agent_startup_logs.output, workspace_agent_startup_logs.id ` type InsertWorkspaceAgentStartupLogsParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - CreatedAt []time.Time `db:"created_at" json:"created_at"` - Output []string `db:"output" json:"output"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + Output []string `db:"output" json:"output"` + OutputLength int32 `db:"output_length" json:"output_length"` } func (q *sqlQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg InsertWorkspaceAgentStartupLogsParams) ([]WorkspaceAgentStartupLog, error) { - rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentStartupLogs, arg.AgentID, pq.Array(arg.CreatedAt), pq.Array(arg.Output)) + rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentStartupLogs, + arg.AgentID, + pq.Array(arg.CreatedAt), + pq.Array(arg.Output), + arg.OutputLength, + ) if err != nil { return nil, err } @@ -5486,9 +5503,9 @@ func (q *sqlQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg In var i WorkspaceAgentStartupLog if err := rows.Scan( &i.AgentID, - &i.ID, &i.CreatedAt, &i.Output, + &i.ID, ); err != nil { return nil, err } diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index bcf1d360d0124..24b4ec172e2ac 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -107,9 +107,14 @@ WHERE ) ORDER BY id ASC; -- name: InsertWorkspaceAgentStartupLogs :many +WITH new_length AS ( + UPDATE workspace_agents SET + startup_logs_length = startup_logs_length + @output_length WHERE workspace_agents.id = @agent_id +) INSERT INTO - workspace_agent_startup_logs -SELECT - @agent_id :: uuid AS agent_id, - unnest(@created_at :: timestamptz [ ]) AS created_at, - unnest(@output :: VARCHAR(1024) [ ]) AS output RETURNING *; + workspace_agent_startup_logs + SELECT + @agent_id :: uuid AS agent_id, + unnest(@created_at :: timestamptz [ ]) AS created_at, + unnest(@output :: VARCHAR(1024) [ ]) AS output + RETURNING workspace_agent_startup_logs.*; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 223c132dc846b..f09f6ebdae374 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -18,7 +18,6 @@ const ( UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); - UniqueWorkspaceAgentStartupLogsAgentIDIDKey UniqueConstraint = "workspace_agent_startup_logs_agent_id_id_key" // ALTER TABLE ONLY workspace_agent_startup_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_id_key UNIQUE (agent_id, id); UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index b7c2f6081abcf..05fffc5e34df7 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -884,11 +884,8 @@ func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) { // @Router /workspaceagents/{workspaceagent}/logs [get] func (api *API) startupScriptLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - workspaceBuild := httpmw.WorkspaceBuildParam(r) - - // TODO: Wait until the logs are written or use SSE. - dblogs, err := api.Database.GetStartupScriptLogsByJobID(ctx, workspaceBuild.JobID) + dblogs, err := api.Database.GetWorkspaceAgentStartupLogsBetween(ctx, database.GetWorkspaceAgentStartupLogsBetweenParams{}) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusOK, codersdk.StartupScriptLog{}) return @@ -905,7 +902,6 @@ func (api *API) startupScriptLogs(rw http.ResponseWriter, r *http.Request) { for _, l := range dblogs { logs = append(logs, codersdk.StartupScriptLog{ AgentID: l.AgentID, - JobID: l.JobID, Output: l.Output, }) } From 0c4d2c36cfb3f1fbe5b9084a376685cc3a15abef Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 13 Mar 2023 09:25:12 -0500 Subject: [PATCH 07/29] Start creating log sending loop --- agent/agent.go | 64 ++++- agent/agent_test.go | 42 +-- coderd/apidoc/docs.go | 62 ++++- coderd/apidoc/swagger.json | 56 +++- coderd/coderd.go | 8 +- coderd/database/dbauthz/querier.go | 8 +- coderd/database/dbauthz/querier_test.go | 6 +- coderd/database/dbfake/databasefake.go | 10 +- .../migrations/000109_add_startup_logs.up.sql | 2 +- coderd/database/querier.go | 4 +- coderd/database/querier_test.go | 14 +- coderd/database/queries.sql.go | 28 +- .../database/queries/provisionerjoblogs.sql | 3 +- coderd/database/queries/workspaceagents.sql | 9 +- coderd/provisionerjobs.go | 103 +++---- coderd/templateversions.go | 4 +- coderd/workspaceagents.go | 252 +++++++++++++++++- coderd/workspacebuilds.go | 34 --- codersdk/agentsdk/agentsdk.go | 19 +- codersdk/workspaceagents.go | 6 + docs/api/templates.md | 12 +- site/src/api/typesGenerated.ts | 14 +- 22 files changed, 540 insertions(+), 220 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 9391678fbaa59..c86b7ade539e2 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -25,7 +25,6 @@ import ( "sync" "time" - "github.com/ammario/prefixsuffix" "github.com/armon/circbuf" "github.com/gliderlabs/ssh" "github.com/google/uuid" @@ -202,6 +201,19 @@ func (a *agent) runLoop(ctx context.Context) { } } +func (a *agent) appendStartupLogsLoop(ctx context.Context, logs []agentsdk.StartupLog) { + for r := retry.New(time.Second, 15*time.Second); r.Wait(ctx); { + err := a.client.AppendStartupLogs(ctx, logs) + if err == nil { + return + } + if errors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { + return + } + a.logger.Error(ctx, "failed to append startup logs", slog.Error(err)) + } +} + // reportLifecycleLoop reports the current lifecycle state once. // Only the latest state is reported, intermediate states may be // lost if the agent can't communicate with the API. @@ -649,9 +661,55 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { _ = fileWriter.Close() }() - saver := &prefixsuffix.Saver{N: 512 << 10} - writer := io.MultiWriter(saver, fileWriter) + startupLogsReader, startupLogsWriter := io.Pipe() + writer := io.MultiWriter(startupLogsWriter, fileWriter) + + queuedLogs := make([]agentsdk.StartupLog, 0) + var flushLogsTimer *time.Timer + var logMutex sync.Mutex + flushQueuedLogs := func() { + logMutex.Lock() + if flushLogsTimer != nil { + flushLogsTimer.Stop() + } + toSend := make([]agentsdk.StartupLog, len(queuedLogs)) + copy(toSend, queuedLogs) + logMutex.Unlock() + for r := retry.New(time.Second, 5*time.Second); r.Wait(ctx); { + err := a.client.AppendStartupLogs(ctx, toSend) + if err == nil { + break + } + a.logger.Error(ctx, "upload startup logs", slog.Error(err)) + } + if ctx.Err() != nil { + return + } + logMutex.Lock() + queuedLogs = queuedLogs[len(toSend):] + logMutex.Unlock() + } + queueLog := func(log agentsdk.StartupLog) { + logMutex.Lock() + defer logMutex.Unlock() + queuedLogs = append(queuedLogs, log) + if flushLogsTimer != nil { + flushLogsTimer.Reset(100 * time.Millisecond) + return + } + if len(queuedLogs) > 100 { + go flushQueuedLogs() + return + } + } + go func() { + scanner := bufio.NewScanner(startupLogsReader) + for scanner.Scan() { + + } + }() defer func() { + // err := a.client.AppendStartupLogs(ctx, agentsdk.InsertOrUpdateStartupLogsRequest{ // Output: string(saver.Bytes()), // }) diff --git a/agent/agent_test.go b/agent/agent_test.go index a6e65d380ce62..cd9348113753a 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -31,8 +31,6 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" "golang.org/x/crypto/ssh" - "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" "golang.org/x/xerrors" "tailscale.com/net/speedtest" "tailscale.com/tailcfg" @@ -744,33 +742,15 @@ func TestAgent_StartupScript(t *testing.T) { } content := "output\n" //nolint:dogsled - _, client, _, fs, _ := setupAgent(t, agentsdk.Metadata{ + _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ StartupScript: "echo " + content, }, 0) - var gotContent string - require.Eventually(t, func() bool { - outputPath := filepath.Join(os.TempDir(), "coder-startup-script.log") - content, err := afero.ReadFile(fs, outputPath) - if err != nil { - t.Logf("read file %q: %s", outputPath, err) - return false - } - if len(content) == 0 { - t.Logf("no content in %q", outputPath) - return false - } - if runtime.GOOS == "windows" { - // Windows uses UTF16! 🪟🪟🪟 - content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content) - if !assert.NoError(t, err) { - return false - } - } - gotContent = string(content) - return true + assert.Eventually(t, func() bool { + got := client.getLifecycleStates() + return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady }, testutil.WaitShort, testutil.IntervalMedium) - require.Equal(t, content, gotContent) - require.Equal(t, content, client.getLogs()) + + require.Len(t, client.getStartupLogs(), 1) } func TestAgent_Lifecycle(t *testing.T) { @@ -1500,7 +1480,7 @@ type client struct { mu sync.Mutex // Protects following. lifecycleStates []codersdk.WorkspaceAgentLifecycle startup agentsdk.PostStartupRequest - logs agentsdk.InsertOrUpdateStartupLogsRequest + logs []agentsdk.StartupLog } func (c *client) Metadata(_ context.Context) (agentsdk.Metadata, error) { @@ -1585,16 +1565,16 @@ func (c *client) PostStartup(_ context.Context, startup agentsdk.PostStartupRequ return nil } -func (c *client) getLogs() string { +func (c *client) getStartupLogs() []agentsdk.StartupLog { c.mu.Lock() defer c.mu.Unlock() - return c.logs.Output + return c.logs } -func (c *client) InsertOrUpdateStartupLogs(_ context.Context, logs agentsdk.InsertOrUpdateStartupLogsRequest) error { +func (c *client) AppendStartupLogs(_ context.Context, logs []agentsdk.StartupLog) error { c.mu.Lock() defer c.mu.Unlock() - c.logs = logs + c.logs = append(c.logs, logs...) return nil } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fe70c1e102ba4..e4ab8b7866445 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2574,13 +2574,13 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Before Unix timestamp", + "description": "Before log id", "name": "before", "in": "query" }, { "type": "integer", - "description": "After Unix timestamp", + "description": "After log id", "name": "after", "in": "query" }, @@ -4298,7 +4298,7 @@ const docTemplate = `{ } } }, - "/workspaceagents/me/startup/logs": { + "/workspaceagents/me/startup-logs": { "patch": { "security": [ { @@ -4537,6 +4537,62 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/startup-logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get startup logs by workspace agent", + "operationId": "get-startup-logs-by-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Before log id", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "description": "After log id", + "name": "after", + "in": "query" + }, + { + "type": "boolean", + "description": "Follow log stream", + "name": "follow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspacebuilds/{workspacebuild}": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9b53b8764e8f1..f79724d430956 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2260,13 +2260,13 @@ }, { "type": "integer", - "description": "Before Unix timestamp", + "description": "Before log id", "name": "before", "in": "query" }, { "type": "integer", - "description": "After Unix timestamp", + "description": "After log id", "name": "after", "in": "query" }, @@ -3776,7 +3776,7 @@ } } }, - "/workspaceagents/me/startup/logs": { + "/workspaceagents/me/startup-logs": { "patch": { "security": [ { @@ -3989,6 +3989,56 @@ } } }, + "/workspaceagents/{workspaceagent}/startup-logs": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get startup logs by workspace agent", + "operationId": "get-startup-logs-by-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Before log id", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "description": "After log id", + "name": "after", + "in": "query" + }, + { + "type": "boolean", + "description": "Follow log stream", + "name": "follow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/workspacebuilds/{workspacebuild}": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index acb87330ec191..c44bab0651229 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -587,10 +587,8 @@ func New(options *Options) *API { r.Route("/me", func(r chi.Router) { r.Use(httpmw.ExtractWorkspaceAgent(options.Database)) r.Get("/metadata", api.workspaceAgentMetadata) - r.Route("/startup", func(r chi.Router) { - r.Post("/", api.postWorkspaceAgentStartup) - r.Patch("/logs", api.patchWorkspaceAgentStartupLogs) - }) + r.Post("/startup", api.postWorkspaceAgentStartup) + r.Patch("/startup-logs", api.patchWorkspaceAgentStartupLogs) r.Post("/app-health", api.postWorkspaceAppHealth) r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/gitsshkey", api.agentGitSSHKey) @@ -606,6 +604,7 @@ func New(options *Options) *API { ) r.Get("/", api.workspaceAgent) r.Get("/pty", api.workspaceAgentPTY) + r.Get("/startup-logs", api.workspaceAgentStartupLogs) r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/coordinate", api.workspaceAgentClientCoordinate) @@ -648,7 +647,6 @@ func New(options *Options) *API { r.Get("/parameters", api.workspaceBuildParameters) r.Get("/resources", api.workspaceBuildResources) r.Get("/state", api.workspaceBuildState) - r.Get("/startup-script-logs", api.startupScriptLogs) }) r.Route("/authcheck", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 2e32771337090..f4e56f910954d 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -263,21 +263,21 @@ func (q *querier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (data return job, nil } -func (q *querier) GetProvisionerLogsByIDBetween(ctx context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) { +func (q *querier) GetProvisionerLogsAfterID(ctx context.Context, arg database.GetProvisionerLogsAfterIDParams) ([]database.ProvisionerJobLog, error) { // Authorized read on job lets the actor also read the logs. _, err := q.GetProvisionerJobByID(ctx, arg.JobID) if err != nil { return nil, err } - return q.db.GetProvisionerLogsByIDBetween(ctx, arg) + return q.db.GetProvisionerLogsAfterID(ctx, arg) } -func (q *querier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsBetweenParams) ([]database.WorkspaceAgentStartupLog, error) { +func (q *querier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsAfterParams) ([]database.WorkspaceAgentStartupLog, error) { _, err := q.GetWorkspaceAgentByID(ctx, arg.AgentID) if err != nil { return nil, err } - return q.db.GetWorkspaceAgentStartupLogsBetween(ctx, arg) + return q.db.GetWorkspaceAgentStartupLogsAfter(ctx, arg) } func (q *querier) GetLicenses(ctx context.Context) ([]database.License, error) { diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index 533fbcefa8d96..da88071a9c2cb 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -287,13 +287,13 @@ func (s *MethodTestSuite) TestProvsionerJob() { b := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{}) check.Args([]uuid.UUID{a.ID, b.ID}).Asserts().Returns(slice.New(a, b)) })) - s.Run("GetProvisionerLogsByIDBetween", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetProvisionerLogsAfterID", s.Subtest(func(db database.Store, check *expects) { w := dbgen.Workspace(s.T(), db, database.Workspace{}) j := dbgen.ProvisionerJob(s.T(), db, database.ProvisionerJob{ Type: database.ProvisionerJobTypeWorkspaceBuild, }) _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: w.ID}) - check.Args(database.GetProvisionerLogsByIDBetweenParams{ + check.Args(database.GetProvisionerLogsAfterIDParams{ JobID: j.ID, }).Asserts(w, rbac.ActionRead).Returns([]database.ProvisionerJobLog{}) })) @@ -1004,7 +1004,7 @@ func (s *MethodTestSuite) TestWorkspace() { ID: agt.ID, }).Asserts(ws, rbac.ActionUpdate).Returns() })) - s.Run("GetWorkspaceAgentStartupLogsBetween", s.Subtest(func(db database.Store, check *expects) { + s.Run("GetWorkspaceAgentStartupLogsAfter", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) check.Args(build.JobID).Asserts(ws, rbac.ActionRead).Returns([]database.WorkspaceAgentStartupLog{}) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 94364fbfa37d7..22c2723daab8a 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -2613,7 +2613,7 @@ func (q *fakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after ti return jobs, nil } -func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) { +func (q *fakeQuerier) GetProvisionerLogsAfterID(_ context.Context, arg database.GetProvisionerLogsAfterIDParams) ([]database.ProvisionerJobLog, error) { if err := validateDatabaseType(arg); err != nil { return nil, err } @@ -2626,9 +2626,6 @@ func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg datab if jobLog.JobID != arg.JobID { continue } - if arg.CreatedBefore != 0 && jobLog.ID > arg.CreatedBefore { - continue - } if arg.CreatedAfter != 0 && jobLog.ID < arg.CreatedAfter { continue } @@ -3514,7 +3511,7 @@ func (q *fakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsBetweenParams) ([]database.WorkspaceAgentStartupLog, error) { +func (q *fakeQuerier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsAfterParams) ([]database.WorkspaceAgentStartupLog, error) { if err := validateDatabaseType(arg); err != nil { return nil, err } @@ -3527,9 +3524,6 @@ func (q *fakeQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, a if log.AgentID != arg.AgentID { continue } - if arg.CreatedBefore != 0 && log.ID > arg.CreatedBefore { - continue - } if arg.CreatedAfter != 0 && log.ID < arg.CreatedAfter { continue } diff --git a/coderd/database/migrations/000109_add_startup_logs.up.sql b/coderd/database/migrations/000109_add_startup_logs.up.sql index 03aedb9d6c425..6ed500c43504b 100644 --- a/coderd/database/migrations/000109_add_startup_logs.up.sql +++ b/coderd/database/migrations/000109_add_startup_logs.up.sql @@ -6,6 +6,6 @@ CREATE TABLE IF NOT EXISTS workspace_agent_startup_logs ( ); CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_startup_logs USING btree (agent_id, id ASC); +-- The maximum length of startup logs is 1MB per workspace agent. ALTER TABLE workspace_agents ADD COLUMN startup_logs_length integer NOT NULL DEFAULT 0 CONSTRAINT max_startup_logs_length CHECK (startup_logs_length <= 1048576); COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; - diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d807e13605a27..c31036e18e7c6 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -87,7 +87,7 @@ type sqlcQuerier interface { GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) - GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) + GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) @@ -121,7 +121,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) - GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg GetWorkspaceAgentStartupLogsBetweenParams) ([]WorkspaceAgentStartupLog, error) + GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg GetWorkspaceAgentStartupLogsAfterParams) ([]WorkspaceAgentStartupLog, error) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 4df7bd15c90c6..823079b7c6be6 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -108,18 +108,20 @@ func TestInsertWorkspaceAgentStartupLogs(t *testing.T) { ResourceID: resource.ID, }) logs, err := db.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ - AgentID: agent.ID, - CreatedAt: []time.Time{database.Now()}, - Output: []string{"first"}, + AgentID: agent.ID, + CreatedAt: []time.Time{database.Now()}, + Output: []string{"first"}, + // 1 MB is the max OutputLength: 1 << 20, }) require.NoError(t, err) require.Equal(t, int64(1), logs[0].ID) _, err = db.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ - AgentID: agent.ID, - CreatedAt: []time.Time{database.Now()}, - Output: []string{"second"}, + AgentID: agent.ID, + CreatedAt: []time.Time{database.Now()}, + Output: []string{"second"}, + OutputLength: 1, }) require.True(t, database.IsStartupLogsLimitError(err)) } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 262bdff25adae..cc83828e195df 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2286,7 +2286,7 @@ func (q *sqlQuerier) InsertProvisionerDaemon(ctx context.Context, arg InsertProv return i, err } -const getProvisionerLogsByIDBetween = `-- name: GetProvisionerLogsByIDBetween :many +const getProvisionerLogsAfterID = `-- name: GetProvisionerLogsAfterID :many SELECT job_id, created_at, source, level, stage, output, id FROM @@ -2295,18 +2295,16 @@ WHERE job_id = $1 AND ( id > $2 - OR id < $3 ) ORDER BY id ASC ` -type GetProvisionerLogsByIDBetweenParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - CreatedAfter int64 `db:"created_after" json:"created_after"` - CreatedBefore int64 `db:"created_before" json:"created_before"` +type GetProvisionerLogsAfterIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + CreatedAfter int64 `db:"created_after" json:"created_after"` } -func (q *sqlQuerier) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) { - rows, err := q.db.QueryContext(ctx, getProvisionerLogsByIDBetween, arg.JobID, arg.CreatedAfter, arg.CreatedBefore) +func (q *sqlQuerier) GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error) { + rows, err := q.db.QueryContext(ctx, getProvisionerLogsAfterID, arg.JobID, arg.CreatedAfter) if err != nil { return nil, err } @@ -5188,7 +5186,7 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst return i, err } -const getWorkspaceAgentStartupLogsBetween = `-- name: GetWorkspaceAgentStartupLogsBetween :many +const getWorkspaceAgentStartupLogsAfter = `-- name: GetWorkspaceAgentStartupLogsAfter :many SELECT agent_id, created_at, output, id FROM @@ -5197,18 +5195,16 @@ WHERE agent_id = $1 AND ( id > $2 - OR id < $3 ) ORDER BY id ASC ` -type GetWorkspaceAgentStartupLogsBetweenParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - CreatedAfter int64 `db:"created_after" json:"created_after"` - CreatedBefore int64 `db:"created_before" json:"created_before"` +type GetWorkspaceAgentStartupLogsAfterParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + CreatedAfter int64 `db:"created_after" json:"created_after"` } -func (q *sqlQuerier) GetWorkspaceAgentStartupLogsBetween(ctx context.Context, arg GetWorkspaceAgentStartupLogsBetweenParams) ([]WorkspaceAgentStartupLog, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceAgentStartupLogsBetween, arg.AgentID, arg.CreatedAfter, arg.CreatedBefore) +func (q *sqlQuerier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg GetWorkspaceAgentStartupLogsAfterParams) ([]WorkspaceAgentStartupLog, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentStartupLogsAfter, arg.AgentID, arg.CreatedAfter) if err != nil { return nil, err } diff --git a/coderd/database/queries/provisionerjoblogs.sql b/coderd/database/queries/provisionerjoblogs.sql index 7d6fece0d8201..b98cf471f0d1a 100644 --- a/coderd/database/queries/provisionerjoblogs.sql +++ b/coderd/database/queries/provisionerjoblogs.sql @@ -1,4 +1,4 @@ --- name: GetProvisionerLogsByIDBetween :many +-- name: GetProvisionerLogsAfterID :many SELECT * FROM @@ -7,7 +7,6 @@ WHERE job_id = @job_id AND ( id > @created_after - OR id < @created_before ) ORDER BY id ASC; -- name: InsertProvisionerJobLogs :many diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 24b4ec172e2ac..3b71d1f300c14 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -94,7 +94,7 @@ SET WHERE id = $1; --- name: GetWorkspaceAgentStartupLogsBetween :many +-- name: GetWorkspaceAgentStartupLogsAfter :many SELECT * FROM @@ -103,7 +103,6 @@ WHERE agent_id = $1 AND ( id > @created_after - OR id < @created_before ) ORDER BY id ASC; -- name: InsertWorkspaceAgentStartupLogs :many @@ -118,3 +117,9 @@ INSERT INTO unnest(@created_at :: timestamptz [ ]) AS created_at, unnest(@output :: VARCHAR(1024) [ ]) AS output RETURNING workspace_agent_startup_logs.*; + +-- If an agent hasn't connected in the last 7 days, we purge it's logs. +-- Logs can take up a lot of space, so it's important we clean up frequently. +DELETE FROM workspace_agent_startup_logs WHERE agent_id IN + (SELECT id FROM workspace_agents WHERE last_connected_at IS NOT NULL + AND last_connected_at < NOW() - INTERVAL '7 day'); diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index aac27cfd95d9b..5ec08574b6a19 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -25,57 +25,18 @@ import ( // Returns provisioner logs based on query parameters. // The intended usage for a client to stream all logs (with JS API): -// const timestamp = new Date().getTime(); -// 1. GET /logs?before= -// 2. GET /logs?after=&follow +// GET /logs +// GET /logs?after=&follow // The combination of these responses should provide all current logs // to the consumer, and future logs are streamed in the follow request. func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job database.ProvisionerJob) { var ( - ctx = r.Context() - actor, _ = dbauthz.ActorFromContext(ctx) - logger = api.Logger.With(slog.F("job_id", job.ID)) - follow = r.URL.Query().Has("follow") - afterRaw = r.URL.Query().Get("after") - beforeRaw = r.URL.Query().Get("before") + ctx = r.Context() + actor, _ = dbauthz.ActorFromContext(ctx) + logger = api.Logger.With(slog.F("job_id", job.ID)) + follow = r.URL.Query().Has("follow") + afterRaw = r.URL.Query().Get("after") ) - if beforeRaw != "" && follow { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Query param \"before\" cannot be used with \"follow\".", - }) - return - } - - // if we are following logs, start the subscription before we query the database, so that we don't miss any logs - // between the end of our query and the start of the subscription. We might get duplicates, so we'll keep track - // of processed IDs. - var bufferedLogs <-chan *database.ProvisionerJobLog - if follow { - bl, closeFollow, err := api.followLogs(actor, job.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error watching provisioner logs.", - Detail: err.Error(), - }) - return - } - defer closeFollow() - bufferedLogs = bl - - // Next query the job itself to see if it is complete. If so, the historical query to the database will return - // the full set of logs. It's a little sad to have to query the job again, given that our caller definitely - // has, but we need to query it *after* we start following the pubsub to avoid a race condition where the job - // completes between the prior query and the start of following the pubsub. A more substantial refactor could - // avoid this, but not worth it for one fewer query at this point. - job, err = api.Database.GetProvisionerJobByID(ctx, job.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error querying job.", - Detail: err.Error(), - }) - return - } - } var after int64 // Only fetch logs created after the time provided. @@ -92,26 +53,10 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job return } } - var before int64 - // Only fetch logs created before the time provided. - if beforeRaw != "" { - var err error - before, err = strconv.ParseInt(beforeRaw, 10, 64) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Query param \"before\" must be an integer.", - Validations: []codersdk.ValidationError{ - {Field: "before", Detail: "Must be an integer"}, - }, - }) - return - } - } - logs, err := api.Database.GetProvisionerLogsByIDBetween(ctx, database.GetProvisionerLogsByIDBetweenParams{ - JobID: job.ID, - CreatedAfter: after, - CreatedBefore: before, + logs, err := api.Database.GetProvisionerLogsAfterID(ctx, database.GetProvisionerLogsAfterIDParams{ + JobID: job.ID, + CreatedAfter: after, }) if errors.Is(err, sql.ErrNoRows) { err = nil @@ -162,11 +107,27 @@ func (api *API) provisionerJobLogs(rw http.ResponseWriter, r *http.Request, job } } if job.CompletedAt.Valid { - // job was complete before we queried the database for historical logs, meaning we got everything. No need - // to stream anything from the bufferedLogs. + // job was complete before we queried the database for historical logs return } + // if we are following logs, start the subscription before we query the database, so that we don't miss any logs + // between the end of our query and the start of the subscription. We might get duplicates, so we'll keep track + // of processed IDs. + var bufferedLogs <-chan *database.ProvisionerJobLog + if follow { + bl, closeFollow, err := api.followProvisionerJobLogs(actor, job.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error watching provisioner logs.", + Detail: err.Error(), + }) + return + } + defer closeFollow() + bufferedLogs = bl + } + for { select { case <-ctx.Done(): @@ -374,7 +335,7 @@ type provisionerJobLogsMessage struct { EndOfLogs bool `json:"end_of_logs,omitempty"` } -func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *database.ProvisionerJobLog, func(), error) { +func (api *API) followProvisionerJobLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *database.ProvisionerJobLog, func(), error) { logger := api.Logger.With(slog.F("job_id", jobID)) var ( @@ -411,7 +372,7 @@ func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *databas // CreatedAfter is sent when logs are streaming! if jlMsg.CreatedAfter != 0 { - logs, err := api.Database.GetProvisionerLogsByIDBetween(dbauthz.As(ctx, actor), database.GetProvisionerLogsByIDBetweenParams{ + logs, err := api.Database.GetProvisionerLogsAfterID(dbauthz.As(ctx, actor), database.GetProvisionerLogsAfterIDParams{ JobID: jobID, CreatedAfter: jlMsg.CreatedAfter, }) @@ -435,7 +396,7 @@ func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *databas // so we fetch logs after the last ID we've seen and send them! if jlMsg.EndOfLogs { endOfLogs.Store(true) - logs, err := api.Database.GetProvisionerLogsByIDBetween(dbauthz.As(ctx, actor), database.GetProvisionerLogsByIDBetweenParams{ + logs, err := api.Database.GetProvisionerLogsAfterID(dbauthz.As(ctx, actor), database.GetProvisionerLogsAfterIDParams{ JobID: jobID, CreatedAfter: lastSentLogID.Load(), }) @@ -450,8 +411,6 @@ func (api *API) followLogs(actor rbac.Subject, jobID uuid.UUID) (<-chan *databas logger.Debug(ctx, "got End of Logs") bufferedLogs <- nil } - - lastSentLogID.Store(jlMsg.CreatedAfter) }, ) if err != nil { diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 2718f08b74bbd..416b70d3c37c7 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1523,8 +1523,8 @@ func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request // @Produce json // @Tags Templates // @Param templateversion path string true "Template version ID" format(uuid) -// @Param before query int false "Before Unix timestamp" -// @Param after query int false "After Unix timestamp" +// @Param before query int false "Before log id" +// @Param after query int false "After log id" // @Param follow query bool false "Follow log stream" // @Success 200 {array} codersdk.ProvisionerJobLog // @Router /templateversions/{templateversion}/logs [get] diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 39b23372b86be..76dffa38e8ea7 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -13,6 +13,7 @@ import ( "net/url" "strconv" "strings" + "sync/atomic" "time" "github.com/google/uuid" @@ -227,7 +228,7 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques // @Tags Agents // @Param request body agentsdk.InsertOrUpdateStartupLogsRequest true "Startup logs" // @Success 200 -// @Router /workspaceagents/me/startup/logs [patch] +// @Router /workspaceagents/me/startup-logs [patch] // @x-apidocgen {"skip": true} func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -240,26 +241,251 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R createdAt := make([]time.Time, 0) output := make([]string, 0) + outputLength := 0 for _, log := range req { createdAt = append(createdAt, log.CreatedAt) output = append(output, log.Output) + outputLength += len(log.Output) } - _, err := api.Database.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ - AgentID: workspaceAgent.ID, - CreatedAt: createdAt, - Output: output, + logs, err := api.Database.InsertWorkspaceAgentStartupLogs(ctx, database.InsertWorkspaceAgentStartupLogsParams{ + AgentID: workspaceAgent.ID, + CreatedAt: createdAt, + Output: output, + OutputLength: int32(outputLength), }) if err != nil { + if database.IsStartupLogsLimitError(err) { + httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{ + Message: "Startup logs limit exceeded", + Detail: err.Error(), + }) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to upload startup logs", Detail: err.Error(), }) return } + lowestID := logs[0].ID + // Publish by the lowest log ID inserted so the + // log stream will fetch everything from that point. + data, err := json.Marshal(agentsdk.StartupLogsNotifyMessage{ + CreatedAfter: lowestID - 1, + }) + if err != nil { + + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal startup logs notify message", + Detail: err.Error(), + }) + return + } + err = api.Pubsub.Publish(agentsdk.StartupLogsNotifyChannel(workspaceAgent.ID), data) + if err != nil { + // We don't want to return an error to the agent here, + // otherwise it might try to reinsert the logs. + api.Logger.Warn(ctx, "failed to publish startup logs notify message", slog.Error(err)) + } httpapi.Write(ctx, rw, http.StatusOK, nil) } +// workspaceAgentStartupLogs returns the logs sent from a workspace agent +// during startup. +// +// @Summary Get startup logs by workspace agent +// @ID get-startup-logs-by-workspace-agent +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Param before query int false "Before log id" +// @Param after query int false "After log id" +// @Param follow query bool false "Follow log stream" +// @Success 200 +// @Router /workspaceagents/{workspaceagent}/startup-logs [get] +// @x-apidocgen {"skip": true} +func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) { + // This mostly copies how provisioner job logs are streamed! + var ( + ctx = r.Context() + agent = httpmw.WorkspaceAgent(r) + logger = api.Logger.With(slog.F("workspace_agent_id", agent.ID)) + follow = r.URL.Query().Has("follow") + afterRaw = r.URL.Query().Get("after") + ) + + var after int64 + // Only fetch logs created after the time provided. + if afterRaw != "" { + var err error + after, err = strconv.ParseInt(afterRaw, 10, 64) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query param \"after\" must be an integer.", + Validations: []codersdk.ValidationError{ + {Field: "after", Detail: "Must be an integer"}, + }, + }) + return + } + } + + logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ + AgentID: agent.ID, + CreatedAfter: after, + }) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching provisioner logs.", + Detail: err.Error(), + }) + return + } + if logs == nil { + logs = []database.WorkspaceAgentStartupLog{} + } + + if !follow { + logger.Debug(ctx, "Finished non-follow job logs") + httpapi.Write(ctx, rw, http.StatusOK, convertWorkspaceAgentStartupLogs(logs)) + return + } + + api.WebsocketWaitMutex.Lock() + api.WebsocketWaitGroup.Add(1) + api.WebsocketWaitMutex.Unlock() + defer api.WebsocketWaitGroup.Done() + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to accept websocket.", + Detail: err.Error(), + }) + return + } + go httpapi.Heartbeat(ctx, conn) + + ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText) + defer wsNetConn.Close() // Also closes conn. + + logIdsDone := make(map[int64]bool) + + // The Go stdlib JSON encoder appends a newline character after message write. + encoder := json.NewEncoder(wsNetConn) + for _, provisionerJobLog := range logs { + logIdsDone[provisionerJobLog.ID] = true + err = encoder.Encode(convertWorkspaceAgentStartupLog(provisionerJobLog)) + if err != nil { + return + } + } + if agent.LifecycleState == database.WorkspaceAgentLifecycleStateReady { + // The startup script has finished running, so we can close the connection. + return + } + + var ( + bufferedLogs = make(chan *database.WorkspaceAgentStartupLog, 128) + endOfLogs atomic.Bool + lastSentLogID atomic.Int64 + ) + + sendLog := func(log *database.WorkspaceAgentStartupLog) { + select { + case bufferedLogs <- log: + lastSentLogID.Store(log.ID) + default: + logger.Warn(ctx, "workspace agent startup log overflowing channel") + } + } + + closeSubscribe, err := api.Pubsub.Subscribe( + agentsdk.StartupLogsNotifyChannel(agent.ID), + func(ctx context.Context, message []byte) { + if endOfLogs.Load() { + return + } + jlMsg := agentsdk.StartupLogsNotifyMessage{} + err := json.Unmarshal(message, &jlMsg) + if err != nil { + logger.Warn(ctx, "invalid startup logs notify message", slog.Error(err)) + return + } + + if jlMsg.CreatedAfter != 0 { + logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ + AgentID: agent.ID, + CreatedAfter: jlMsg.CreatedAfter, + }) + if err != nil { + logger.Warn(ctx, "failed to get workspace agent startup logs after", slog.Error(err)) + return + } + for _, log := range logs { + if endOfLogs.Load() { + return + } + log := log + sendLog(&log) + } + } + + if jlMsg.EndOfLogs { + endOfLogs.Store(true) + logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ + AgentID: agent.ID, + CreatedAfter: lastSentLogID.Load(), + }) + if err != nil { + logger.Warn(ctx, "get workspace agent startup logs after", slog.Error(err)) + return + } + for _, log := range logs { + log := log + sendLog(&log) + } + bufferedLogs <- nil + } + }, + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to subscribe to startup logs.", + Detail: err.Error(), + }) + return + } + defer closeSubscribe() + + for { + select { + case <-ctx.Done(): + logger.Debug(context.Background(), "job logs context canceled") + return + case log, ok := <-bufferedLogs: + // A nil log is sent when complete! + if !ok || log == nil { + logger.Debug(context.Background(), "reached the end of published logs") + return + } + if logIdsDone[log.ID] { + logger.Debug(ctx, "subscribe duplicated log") + } else { + err = encoder.Encode(convertWorkspaceAgentStartupLog(*log)) + if err != nil { + return + } + } + } + } +} + // workspaceAgentPTY spawns a PTY and pipes it over a WebSocket. // This is used for the web terminal. // @@ -1611,3 +1837,19 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock Conn: nc, } } + +func convertWorkspaceAgentStartupLogs(logs []database.WorkspaceAgentStartupLog) []codersdk.WorkspaceAgentStartupLog { + sdk := make([]codersdk.WorkspaceAgentStartupLog, 0, len(logs)) + for _, log := range logs { + sdk = append(sdk, convertWorkspaceAgentStartupLog(log)) + } + return sdk +} + +func convertWorkspaceAgentStartupLog(log database.WorkspaceAgentStartupLog) codersdk.WorkspaceAgentStartupLog { + return codersdk.WorkspaceAgentStartupLog{ + ID: log.ID, + CreatedAt: log.CreatedAt, + Output: log.Output, + } +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 05fffc5e34df7..8c89a47b63754 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -874,40 +874,6 @@ func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) { api.provisionerJobLogs(rw, r, job) } -// @Summary Stream workspace agent startup logs -// @ID stream-startup-script-logs -// @Security CoderSessionToken -// @Produce json -// @Tags Agents -// @Param workspacebuild path string true "Workspace build ID" -// @Success 200 {object} []codersdk.StartupScriptLog -// @Router /workspaceagents/{workspaceagent}/logs [get] -func (api *API) startupScriptLogs(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - dblogs, err := api.Database.GetWorkspaceAgentStartupLogsBetween(ctx, database.GetWorkspaceAgentStartupLogsBetweenParams{}) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusOK, codersdk.StartupScriptLog{}) - return - } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Error getting startup logs", - Detail: err.Error(), - }) - return - } - - logs := []codersdk.StartupScriptLog{} - for _, l := range dblogs { - logs = append(logs, codersdk.StartupScriptLog{ - AgentID: l.AgentID, - Output: l.Output, - }) - } - httpapi.Write(ctx, rw, http.StatusOK, logs) -} - // @Summary Get provisioner state for workspace build // @ID get-provisioner-state-for-workspace-build // @Security CoderSessionToken diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 452fd65d43fbe..798198a3b8543 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -516,17 +516,15 @@ func (c *Client) PostStartup(ctx context.Context, req PostStartupRequest) error return nil } -type InsertOrUpdateStartupLogsRequest struct { - Output string -} - type StartupLog struct { CreatedAt time.Time `json:"created_at"` Output string `json:"output"` } +// AppendStartupLogs writes log messages to the agent startup script. +// Log messages are limited to 1MB in total. func (c *Client) AppendStartupLogs(ctx context.Context, req []StartupLog) error { - res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/startup/logs", req) + res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/startup-logs", req) if err != nil { return err } @@ -610,3 +608,14 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock Conn: nc, } } + +// StartupLogsNotifyChannel returns the channel name responsible for notifying +// of new startup logs. +func StartupLogsNotifyChannel(agentID uuid.UUID) string { + return fmt.Sprintf("workspace-agent-startup-logs:%s", agentID) +} + +type StartupLogsNotifyMessage struct { + CreatedAfter int64 `json:"created_after"` + EndOfLogs bool `json:"end_of_logs"` +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 4d953f8e050d5..231a68172789c 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -339,3 +339,9 @@ const ( GitProviderGitLab GitProvider = "gitlab" GitProviderBitBucket GitProvider = "bitbucket" ) + +type WorkspaceAgentStartupLog struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + Output string `json:"output"` +} diff --git a/docs/api/templates.md b/docs/api/templates.md index 8f89d76462290..9e9f6f58a1c36 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1832,12 +1832,12 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/l ### Parameters -| Name | In | Type | Required | Description | -| ----------------- | ----- | ------------ | -------- | --------------------- | -| `templateversion` | path | string(uuid) | true | Template version ID | -| `before` | query | integer | false | Before Unix timestamp | -| `after` | query | integer | false | After Unix timestamp | -| `follow` | query | boolean | false | Follow log stream | +| Name | In | Type | Required | Description | +| ----------------- | ----- | ------------ | -------- | ------------------- | +| `templateversion` | path | string(uuid) | true | Template version ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | ### Example responses diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 62db12850c870..06e43539959e6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -681,13 +681,6 @@ export interface ServiceBannerConfig { readonly background_color?: string } -// From codersdk/workspacebuilds.go -export interface StartupScriptLog { - readonly agent_id: string - readonly job_id: string - readonly output: string -} - // From codersdk/deployment.go export interface SessionCountDeploymentStats { readonly vscode: number @@ -696,6 +689,13 @@ export interface SessionCountDeploymentStats { readonly reconnecting_pty: number } +// From codersdk/workspacebuilds.go +export interface StartupScriptLog { + readonly agent_id: string + readonly job_id: string + readonly output: string +} + // From codersdk/deployment.go export interface SupportConfig { // Named type "github.com/coder/coder/cli/clibase.Struct[[]github.com/coder/coder/codersdk.LinkConfig]" unknown, using "any" From 1bb700f5d010575f3a476cfbd4db181b2d59115c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 13 Mar 2023 14:41:23 +0000 Subject: [PATCH 08/29] Add log sending to the agent --- agent/agent.go | 44 +++++++++------ agent/agent_test.go | 9 +++- coderd/apidoc/docs.go | 75 ++++++-------------------- coderd/apidoc/swagger.json | 71 ++++++------------------ coderd/workspaceagents.go | 2 +- coderd/wsconncache/wsconncache_test.go | 4 ++ docs/api/agents.md | 52 ------------------ docs/api/schemas.md | 48 ++++++----------- 8 files changed, 86 insertions(+), 219 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index c86b7ade539e2..96f4c1e01624c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -41,6 +41,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent/usershell" "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/gitauth" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -662,16 +663,23 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { }() startupLogsReader, startupLogsWriter := io.Pipe() + defer func() { + _ = startupLogsReader.Close() + _ = startupLogsWriter.Close() + }() writer := io.MultiWriter(startupLogsWriter, fileWriter) queuedLogs := make([]agentsdk.StartupLog, 0) var flushLogsTimer *time.Timer var logMutex sync.Mutex - flushQueuedLogs := func() { + var logsSending bool + sendLogs := func() { logMutex.Lock() - if flushLogsTimer != nil { - flushLogsTimer.Stop() + if logsSending { + logMutex.Unlock() + return } + logsSending = true toSend := make([]agentsdk.StartupLog, len(queuedLogs)) copy(toSend, queuedLogs) logMutex.Unlock() @@ -683,9 +691,13 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { a.logger.Error(ctx, "upload startup logs", slog.Error(err)) } if ctx.Err() != nil { + logMutex.Lock() + logsSending = false + logMutex.Unlock() return } logMutex.Lock() + logsSending = false queuedLogs = queuedLogs[len(toSend):] logMutex.Unlock() } @@ -698,25 +710,25 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { return } if len(queuedLogs) > 100 { - go flushQueuedLogs() + go sendLogs() return } + flushLogsTimer = time.AfterFunc(100*time.Millisecond, func() { + sendLogs() + }) } - go func() { + err = a.trackConnGoroutine(func() { scanner := bufio.NewScanner(startupLogsReader) for scanner.Scan() { - + queueLog(agentsdk.StartupLog{ + CreatedAt: database.Now(), + Output: scanner.Text(), + }) } - }() - defer func() { - - // err := a.client.AppendStartupLogs(ctx, agentsdk.InsertOrUpdateStartupLogsRequest{ - // Output: string(saver.Bytes()), - // }) - // if err != nil { - // a.logger.Error(ctx, "upload startup logs", slog.Error(err)) - // } - }() + }) + if err != nil { + return xerrors.Errorf("track conn goroutine: %w", err) + } cmd, err := a.createCommand(ctx, script, nil) if err != nil { diff --git a/agent/agent_test.go b/agent/agent_test.go index cd9348113753a..ed7d8ebe2c717 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -740,10 +740,14 @@ func TestAgent_StartupScript(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("This test doesn't work on Windows for some reason...") } - content := "output\n" + output := "something" + command := "sh -c 'echo " + output + "'" + if runtime.GOOS == "windows" { + command = "cmd.exe /c echo " + output + } //nolint:dogsled _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ - StartupScript: "echo " + content, + StartupScript: command, }, 0) assert.Eventually(t, func() bool { got := client.getLifecycleStates() @@ -751,6 +755,7 @@ func TestAgent_StartupScript(t *testing.T) { }, testutil.WaitShort, testutil.IntervalMedium) require.Len(t, client.getStartupLogs(), 1) + require.Equal(t, output, client.getStartupLogs()[0].Output) } func TestAgent_Lifecycle(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e4ab8b7866445..d3c935e711a83 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4323,7 +4323,10 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/agentsdk.InsertOrUpdateStartupLogsRequest" + "type": "array", + "items": { + "$ref": "#/definitions/agentsdk.StartupLog" + } } } ], @@ -4471,43 +4474,6 @@ const docTemplate = `{ } } }, - "/workspaceagents/{workspaceagent}/logs": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agents" - ], - "summary": "Stream workspace agent startup logs", - "operationId": "stream-startup-script-logs", - "parameters": [ - { - "type": "string", - "description": "Workspace build ID", - "name": "workspacebuild", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.StartupScriptLog" - } - } - } - } - } - }, "/workspaceagents/{workspaceagent}/pty": { "get": { "security": [ @@ -5327,14 +5293,6 @@ const docTemplate = `{ } } }, - "agentsdk.InsertOrUpdateStartupLogsRequest": { - "type": "object", - "properties": { - "output": { - "type": "string" - } - } - }, "agentsdk.Metadata": { "type": "object", "properties": { @@ -5411,6 +5369,17 @@ const docTemplate = `{ } } }, + "agentsdk.StartupLog": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "output": { + "type": "string" + } + } + }, "agentsdk.Stats": { "type": "object", "properties": { @@ -7825,20 +7794,6 @@ const docTemplate = `{ } } }, - "codersdk.StartupScriptLog": { - "type": "object", - "properties": { - "agent_id": { - "type": "string" - }, - "job_id": { - "type": "string" - }, - "output": { - "type": "string" - } - } - }, "codersdk.SupportConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f79724d430956..fb38d035c85c8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3795,7 +3795,10 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/agentsdk.InsertOrUpdateStartupLogsRequest" + "type": "array", + "items": { + "$ref": "#/definitions/agentsdk.StartupLog" + } } } ], @@ -3929,39 +3932,6 @@ } } }, - "/workspaceagents/{workspaceagent}/logs": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["Agents"], - "summary": "Stream workspace agent startup logs", - "operationId": "stream-startup-script-logs", - "parameters": [ - { - "type": "string", - "description": "Workspace build ID", - "name": "workspacebuild", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.StartupScriptLog" - } - } - } - } - } - }, "/workspaceagents/{workspaceagent}/pty": { "get": { "security": [ @@ -4696,14 +4666,6 @@ } } }, - "agentsdk.InsertOrUpdateStartupLogsRequest": { - "type": "object", - "properties": { - "output": { - "type": "string" - } - } - }, "agentsdk.Metadata": { "type": "object", "properties": { @@ -4780,6 +4742,17 @@ } } }, + "agentsdk.StartupLog": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "output": { + "type": "string" + } + } + }, "agentsdk.Stats": { "type": "object", "properties": { @@ -7020,20 +6993,6 @@ } } }, - "codersdk.StartupScriptLog": { - "type": "object", - "properties": { - "agent_id": { - "type": "string" - }, - "job_id": { - "type": "string" - }, - "output": { - "type": "string" - } - } - }, "codersdk.SupportConfig": { "type": "object", "properties": { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 76dffa38e8ea7..f8fe8f82ff5e7 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -226,7 +226,7 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques // @Accept json // @Produce json // @Tags Agents -// @Param request body agentsdk.InsertOrUpdateStartupLogsRequest true "Startup logs" +// @Param request body []agentsdk.StartupLog true "Startup logs" // @Success 200 // @Router /workspaceagents/me/startup-logs [patch] // @x-apidocgen {"skip": true} diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index 6abc5609ec6dc..eec11ee1bfb09 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -249,3 +249,7 @@ func (*client) PostAppHealth(_ context.Context, _ agentsdk.PostAppHealthsRequest func (*client) PostStartup(_ context.Context, _ agentsdk.PostStartupRequest) error { return nil } + +func (*client) AppendStartupLogs(_ context.Context, _ []agentsdk.StartupLog) error { + return nil +} diff --git a/docs/api/agents.md b/docs/api/agents.md index 366307ee0cfe2..6031e8698815b 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -692,58 +692,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/lis To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Stream workspace agent startup logs - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/logs \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /workspaceagents/{workspaceagent}/logs` - -### Parameters - -| Name | In | Type | Required | Description | -| ---------------- | ---- | ------ | -------- | ------------------ | -| `workspacebuild` | path | string | true | Workspace build ID | - -### Example responses - -> 200 Response - -```json -[ - { - "agent_id": "string", - "job_id": "string", - "output": "string" - } -] -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.StartupScriptLog](schemas.md#codersdkstartupscriptlog) | - -

Response Schema

- -Status Code **200** - -| Name | Type | Required | Restrictions | Description | -| -------------- | ------ | -------- | ------------ | ----------- | -| `[array item]` | array | false | | | -| `» agent_id` | string | false | | | -| `» job_id` | string | false | | | -| `» output` | string | false | | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Open PTY to workspace agent ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5f06eedd5c61e..369b5d35ebad2 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -94,20 +94,6 @@ | ---------------- | ------ | -------- | ------------ | ----------- | | `json_web_token` | string | true | | | -## agentsdk.InsertOrUpdateStartupLogsRequest - -```json -{ - "output": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| -------- | ------ | -------- | ------------ | ----------- | -| `output` | string | false | | | - ## agentsdk.Metadata ```json @@ -262,6 +248,22 @@ | `expanded_directory` | string | false | | | | `version` | string | false | | | +## agentsdk.StartupLog + +```json +{ + "created_at": "string", + "output": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | ------ | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `output` | string | false | | | + ## agentsdk.Stats ```json @@ -3370,24 +3372,6 @@ Parameter represents a set value for the scope. | `ssh` | integer | false | | | | `vscode` | integer | false | | | -## codersdk.StartupScriptLog - -```json -{ - "agent_id": "string", - "job_id": "string", - "output": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| ---------- | ------ | -------- | ------------ | ----------- | -| `agent_id` | string | false | | | -| `job_id` | string | false | | | -| `output` | string | false | | | - ## codersdk.SupportConfig ```json From 736705f87068ded10740972bfdaeefb31e46f24c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 13 Mar 2023 17:34:18 +0000 Subject: [PATCH 09/29] Add tests for streaming logs --- agent/agent.go | 32 +++----------- agent/agent_test.go | 4 +- coderd/provisionerjobs_test.go | 32 -------------- coderd/workspaceagents.go | 8 ++-- coderd/workspaceagents_test.go | 59 +++++++++++++++++++++++++ coderd/wsconncache/wsconncache_test.go | 2 +- codersdk/agentsdk/agentsdk.go | 8 +++- codersdk/provisionerdaemons.go | 23 ---------- codersdk/templateversions.go | 11 ----- codersdk/workspaceagents.go | 60 ++++++++++++++++++++++++++ codersdk/workspacebuilds.go | 19 -------- site/src/api/api.ts | 2 +- site/src/api/typesGenerated.ts | 7 +++ 13 files changed, 147 insertions(+), 120 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 96f4c1e01624c..ca910f5d5056d 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -88,7 +88,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 - AppendStartupLogs(ctx context.Context, req []agentsdk.StartupLog) error + PatchStartupLogs(ctx context.Context, req agentsdk.PatchStartupLogs) error } func New(options Options) io.Closer { @@ -202,19 +202,6 @@ func (a *agent) runLoop(ctx context.Context) { } } -func (a *agent) appendStartupLogsLoop(ctx context.Context, logs []agentsdk.StartupLog) { - for r := retry.New(time.Second, 15*time.Second); r.Wait(ctx); { - err := a.client.AppendStartupLogs(ctx, logs) - if err == nil { - return - } - if errors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { - return - } - a.logger.Error(ctx, "failed to append startup logs", slog.Error(err)) - } -} - // reportLifecycleLoop reports the current lifecycle state once. // Only the latest state is reported, intermediate states may be // lost if the agent can't communicate with the API. @@ -680,25 +667,20 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { return } logsSending = true - toSend := make([]agentsdk.StartupLog, len(queuedLogs)) - copy(toSend, queuedLogs) + logsToSend := queuedLogs + queuedLogs = make([]agentsdk.StartupLog, 0) logMutex.Unlock() for r := retry.New(time.Second, 5*time.Second); r.Wait(ctx); { - err := a.client.AppendStartupLogs(ctx, toSend) + err := a.client.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: logsToSend, + }) if err == nil { break } - a.logger.Error(ctx, "upload startup logs", slog.Error(err)) - } - if ctx.Err() != nil { - logMutex.Lock() - logsSending = false - logMutex.Unlock() - return + a.logger.Error(ctx, "upload startup logs", slog.Error(err), slog.F("to_send", logsToSend)) } logMutex.Lock() logsSending = false - queuedLogs = queuedLogs[len(toSend):] logMutex.Unlock() } queueLog := func(log agentsdk.StartupLog) { diff --git a/agent/agent_test.go b/agent/agent_test.go index ed7d8ebe2c717..3d055856d6778 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1576,10 +1576,10 @@ func (c *client) getStartupLogs() []agentsdk.StartupLog { return c.logs } -func (c *client) AppendStartupLogs(_ context.Context, logs []agentsdk.StartupLog) error { +func (c *client) PatchStartupLogs(_ context.Context, logs agentsdk.PatchStartupLogs) error { c.mu.Lock() defer c.mu.Unlock() - c.logs = append(c.logs, logs...) + c.logs = append(c.logs, logs.Logs...) return nil } diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index 1e0b327d355e6..505031d50c949 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -89,36 +89,4 @@ func TestProvisionerJobLogs(t *testing.T) { } } }) - - t.Run("List", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionApply: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Log{ - Log: &proto.Log{ - Level: proto.LogLevel_INFO, - Output: "log-output", - }, - }, - }, { - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{}, - }, - }}, - }) - 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) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - logs, err := client.WorkspaceBuildLogsBefore(ctx, workspace.LatestBuild.ID, 0) - require.NoError(t, err) - require.Greater(t, len(logs), 1) - }) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index f8fe8f82ff5e7..84f51f2067130 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -226,7 +226,7 @@ func (api *API) postWorkspaceAgentStartup(rw http.ResponseWriter, r *http.Reques // @Accept json // @Produce json // @Tags Agents -// @Param request body []agentsdk.StartupLog true "Startup logs" +// @Param request body agentsdk.PatchStartupLogs true "Startup logs" // @Success 200 // @Router /workspaceagents/me/startup-logs [patch] // @x-apidocgen {"skip": true} @@ -234,7 +234,7 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R ctx := r.Context() workspaceAgent := httpmw.WorkspaceAgent(r) - var req []agentsdk.StartupLog + var req agentsdk.PatchStartupLogs if !httpapi.Read(ctx, rw, r, &req) { return } @@ -242,7 +242,7 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R createdAt := make([]time.Time, 0) output := make([]string, 0) outputLength := 0 - for _, log := range req { + for _, log := range req.Logs { createdAt = append(createdAt, log.CreatedAt) output = append(output, log.Output) outputLength += len(log.Output) @@ -311,7 +311,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques // This mostly copies how provisioner job logs are streamed! var ( ctx = r.Context() - agent = httpmw.WorkspaceAgent(r) + agent = httpmw.WorkspaceAgentParam(r) logger = api.Logger.With(slog.F("workspace_agent_id", agent.ID)) follow = r.URL.Query().Has("follow") afterRaw = r.URL.Query().Get("after") diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index e4937143b4291..e2a5d3aad0714 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -175,6 +175,65 @@ func TestWorkspaceAgent(t *testing.T) { }) } +func TestWorkspaceAgentStartup(t *testing.T) { + t.Parallel() + ctx, cancelFunc := testutil.Context(t) + defer cancelFunc() + 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{{ + 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) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err := agentClient.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: []agentsdk.StartupLog{{ + CreatedAt: database.Now(), + Output: "testing", + }}, + }) + require.NoError(t, err) + + logs, closer, err := client.WorkspaceAgentStartupLogsAfter(ctx, build.Resources[0].Agents[0].ID, 0) + require.NoError(t, err) + defer func() { + _ = closer.Close() + }() + var log codersdk.WorkspaceAgentStartupLog + select { + case <-ctx.Done(): + case log = <-logs: + } + require.NoError(t, ctx.Err()) + require.Equal(t, "testing", log.Output) + cancelFunc() +} + func TestWorkspaceAgentListen(t *testing.T) { t.Parallel() diff --git a/coderd/wsconncache/wsconncache_test.go b/coderd/wsconncache/wsconncache_test.go index eec11ee1bfb09..557ec27a3a556 100644 --- a/coderd/wsconncache/wsconncache_test.go +++ b/coderd/wsconncache/wsconncache_test.go @@ -250,6 +250,6 @@ func (*client) PostStartup(_ context.Context, _ agentsdk.PostStartupRequest) err return nil } -func (*client) AppendStartupLogs(_ context.Context, _ []agentsdk.StartupLog) error { +func (*client) PatchStartupLogs(_ context.Context, _ agentsdk.PatchStartupLogs) error { return nil } diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 798198a3b8543..e01b18e8a76e1 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -521,9 +521,13 @@ type StartupLog struct { Output string `json:"output"` } -// AppendStartupLogs writes log messages to the agent startup script. +type PatchStartupLogs struct { + Logs []StartupLog `json:"logs"` +} + +// PatchStartupLogs writes log messages to the agent startup script. // Log messages are limited to 1MB in total. -func (c *Client) AppendStartupLogs(ctx context.Context, req []StartupLog) error { +func (c *Client) PatchStartupLogs(ctx context.Context, req PatchStartupLogs) error { res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/startup-logs", req) if err != nil { return err diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 0a751169bf0d3..0479d05ae5221 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -9,8 +9,6 @@ import ( "net" "net/http" "net/http/cookiejar" - "net/url" - "strconv" "time" "github.com/google/uuid" @@ -100,27 +98,6 @@ type ProvisionerJobLog struct { Output string `json:"output"` } -// provisionerJobLogsBefore provides log output that occurred before a time. -// This is abstracted from a specific job type to provide consistency between -// APIs. Logs is the only shared route between jobs. -func (c *Client) provisionerJobLogsBefore(ctx context.Context, path string, before int64) ([]ProvisionerJobLog, error) { - values := url.Values{} - if before != 0 { - values["before"] = []string{strconv.FormatInt(before, 10)} - } - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("%s?%s", path, values.Encode()), nil) - if err != nil { - return nil, err - } - if res.StatusCode != http.StatusOK { - defer res.Body.Close() - return nil, ReadBodyAsError(res) - } - - var logs []ProvisionerJobLog - return logs, json.NewDecoder(res.Body).Decode(&logs) -} - // provisionerJobLogsAfter streams logs that occurred after a specific time. func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { afterQuery := "" diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index 3cb78518a0122..ca4dfbee30ea5 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -186,11 +186,6 @@ func (c *Client) TemplateVersionVariables(ctx context.Context, version uuid.UUID return variables, json.NewDecoder(res.Body).Decode(&variables) } -// TemplateVersionLogsBefore returns logs that occurred before a specific log ID. -func (c *Client) TemplateVersionLogsBefore(ctx context.Context, version uuid.UUID, before int64) ([]ProvisionerJobLog, error) { - return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/templateversions/%s/logs", version), before) -} - // TemplateVersionLogsAfter streams logs for a template version that occurred after a specific log ID. func (c *Client) TemplateVersionLogsAfter(ctx context.Context, version uuid.UUID, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/templateversions/%s/logs", version), after) @@ -253,12 +248,6 @@ func (c *Client) TemplateVersionDryRunResources(ctx context.Context, version, jo return resources, json.NewDecoder(res.Body).Decode(&resources) } -// TemplateVersionDryRunLogsBefore returns logs for a template version dry-run -// that occurred before a specific log ID. -func (c *Client) TemplateVersionDryRunLogsBefore(ctx context.Context, version, job uuid.UUID, before int64) ([]ProvisionerJobLog, error) { - return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/templateversions/%s/dry-run/%s/logs", version, job), before) -} - // TemplateVersionDryRunLogsAfter streams logs for a template version dry-run // that occurred after a specific log ID. func (c *Client) TemplateVersionDryRunLogsAfter(ctx context.Context, version, job uuid.UUID, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 231a68172789c..35f53a5a8b692 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/cookiejar" @@ -314,6 +315,65 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } +func (c *Client) WorkspaceAgentStartupLogsAfter(ctx context.Context, agentID uuid.UUID, after int64) (<-chan WorkspaceAgentStartupLog, io.Closer, error) { + afterQuery := "" + if after != 0 { + afterQuery = fmt.Sprintf("&after=%d", after) + } + followURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/startup-logs?follow%s", agentID, afterQuery)) + if err != nil { + return nil, nil, err + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(followURL, []*http.Cookie{{ + Name: SessionTokenCookie, + Value: c.SessionToken(), + }}) + httpClient := &http.Client{ + Jar: jar, + Transport: c.HTTPClient.Transport, + } + conn, res, err := websocket.Dial(ctx, followURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return nil, nil, err + } + return nil, nil, ReadBodyAsError(res) + } + logs := make(chan WorkspaceAgentStartupLog) + closed := make(chan struct{}) + ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText) + decoder := json.NewDecoder(wsNetConn) + go func() { + defer close(closed) + defer close(logs) + defer conn.Close(websocket.StatusGoingAway, "") + var log WorkspaceAgentStartupLog + for { + err = decoder.Decode(&log) + if err != nil { + return + } + select { + case <-ctx.Done(): + return + case logs <- log: + } + } + }() + return logs, closeFunc(func() error { + _ = wsNetConn.Close() + <-closed + return nil + }), nil +} + // GitProvider is a constant that represents the // type of providers that are supported within Coder. type GitProvider string diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 3194937f37286..545d3c176f991 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -136,30 +136,11 @@ func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error { return nil } -// WorkspaceBuildLogsBefore returns logs that occurred before a specific log ID. -func (c *Client) WorkspaceBuildLogsBefore(ctx context.Context, build uuid.UUID, before int64) ([]ProvisionerJobLog, error) { - return c.provisionerJobLogsBefore(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), before) -} - // WorkspaceBuildLogsAfter streams logs for a workspace build that occurred after a specific log ID. func (c *Client) WorkspaceBuildLogsAfter(ctx context.Context, build uuid.UUID, after int64) (<-chan ProvisionerJobLog, io.Closer, error) { return c.provisionerJobLogsAfter(ctx, fmt.Sprintf("/api/v2/workspacebuilds/%s/logs", build), after) } -// StartupScriptLogs returns the logs from startup scripts. -func (c *Client) StartupScriptLogs(ctx context.Context, build uuid.UUID) ([]StartupScriptLog, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/startup-script-logs", build), nil) - if err != nil { - return nil, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) - } - var log []StartupScriptLog - return log, json.NewDecoder(res.Body).Decode(&log) -} - // WorkspaceBuildState returns the provisioner state of the build. func (c *Client) WorkspaceBuildState(ctx context.Context, build uuid.UUID) ([]byte, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspacebuilds/%s/state", build), nil) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4d4f5a3e730bb..b6ae49b7b2b22 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -810,7 +810,7 @@ export const getStartupScriptLogs = async ( workspaceBuildId: string, ): Promise => { const response = await axios.get( - `/api/v2/workspacebuilds/${workspaceBuildId}/startup-script-logs`, + `/api/v2/workspacebuilds/${workspaceBuildId}/startup-logs`, ) return response.data } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 06e43539959e6..bee49a3d2df10 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1040,6 +1040,13 @@ export interface WorkspaceAgentListeningPortsResponse { readonly ports: WorkspaceAgentListeningPort[] } +// From codersdk/workspaceagents.go +export interface WorkspaceAgentStartupLog { + readonly id: number + readonly created_at: string + readonly output: string +} + // From codersdk/workspaceapps.go export interface WorkspaceApp { readonly id: string From f741523d7327725f6513f3812542f8cf72e6b1db Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 14 Mar 2023 15:23:35 +0000 Subject: [PATCH 10/29] Shorten notify channel name --- codersdk/agentsdk/agentsdk.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index e01b18e8a76e1..6ebec9a42f26a 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -616,7 +616,7 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock // StartupLogsNotifyChannel returns the channel name responsible for notifying // of new startup logs. func StartupLogsNotifyChannel(agentID uuid.UUID) string { - return fmt.Sprintf("workspace-agent-startup-logs:%s", agentID) + return fmt.Sprintf("startup-logs:%s", agentID) } type StartupLogsNotifyMessage struct { From 54c30be3bc92685eb228d6a73b1c81e07a26a6bc Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 14 Mar 2023 22:00:00 +0000 Subject: [PATCH 11/29] Add FE --- codersdk/workspacebuilds.go | 6 - site/src/api/api.ts | 9 + site/src/components/Resources/AgentRow.tsx | 201 ++++++++++-------- site/src/components/Workspace/Workspace.tsx | 34 +-- .../src/pages/WorkspacePage/WorkspacePage.tsx | 28 +-- .../WorkspacePage/WorkspaceReadyPage.tsx | 3 - .../workspaceAgentLogsXService.ts | 91 ++++++++ 7 files changed, 223 insertions(+), 149 deletions(-) create mode 100644 site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 545d3c176f991..c7bdf022d238f 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -102,12 +102,6 @@ type WorkspaceBuildParameter struct { Value string `json:"value"` } -type StartupScriptLog struct { - AgentID uuid.UUID `json:"agent_id"` - JobID uuid.UUID `json:"job_id"` - Output string `json:"output"` -} - // WorkspaceBuild returns a single workspace build for a workspace. // If history is "", the latest version is returned. func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b6ae49b7b2b22..79285de91430c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -668,6 +668,15 @@ export const getWorkspaceBuildLogs = async ( return response.data } +export const getWorkspaceAgentStartupLogs = async ( + agentID: string, +): Promise => { + const response = await axios.get( + `/api/v2/workspaceagents/${agentID}/startup-logs`, + ) + return response.data +} + export const putWorkspaceExtension = async ( workspaceId: string, newDeadline: dayjs.Dayjs, diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index d72a14b21ebbf..801f8b436e03b 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -14,6 +14,9 @@ import { AgentStatus } from "./AgentStatus" import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton" import { useTranslation } from "react-i18next" import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" +import { useMachine } from "@xstate/react" +import { workspaceAgentLogsMachine } from "xServices/workspaceAgentLogs/workspaceAgentLogsXService" +import { Line, Logs } from "components/Logs/Logs" export interface AgentRowProps { agent: WorkspaceAgent @@ -38,108 +41,126 @@ export const AgentRow: FC = ({ }) => { const styles = useStyles() const { t } = useTranslation("agent") + const [logsMachine] = useMachine(workspaceAgentLogsMachine, { + context: { agentID: agent.id }, + }) return ( - - -
- -
-
-
{agent.name}
- - {agent.operating_system} - - - - - - - - - - - - - - {t("unableToConnect")} - - -
-
- + - {showApps && agent.status === "connected" && ( - <> - {agent.apps.map((app) => ( - - ))} - - - {!hideSSHButton && ( - +
+ +
+
+
{agent.name}
+ + {agent.operating_system} + + + + + + + + + + + + + + {t("unableToConnect")} + + +
+
+ + + {showApps && agent.status === "connected" && ( + <> + {agent.apps.map((app) => ( + + ))} + + - )} - {!hideVSCodeDesktopButton && ( - - )} - {applicationsHost !== undefined && applicationsHost !== "" && ( - + {!hideSSHButton && ( + + )} + {!hideVSCodeDesktopButton && ( + + )} + {applicationsHost !== undefined && applicationsHost !== "" && ( + + )} + + )} + {showApps && agent.status === "connecting" && ( + <> + + + + )} + +
+ +
+ {logsMachine.context.startupLogs && ( + ({ + level: "info", + output: log.output, + time: log.created_at, + }), )} - - )} - {showApps && agent.status === "connecting" && ( - <> - - - + /> )} - +
) } diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index fddb4e367d166..bffed709b1f32 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,14 +1,21 @@ import { makeStyles } from "@material-ui/core/styles" +import { Avatar } from "components/Avatar/Avatar" +import { AgentRow } from "components/Resources/AgentRow" +import { + ActiveTransition, + WorkspaceBuildProgress +} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" -import { FC, useEffect, useState } from "react" +import { FC } from "react" import { useNavigate } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" +import { AlertBanner } from "../AlertBanner/AlertBanner" import { BuildsTable } from "../BuildsTable/BuildsTable" import { Margins } from "../Margins/Margins" import { PageHeader, PageHeaderSubtitle, - PageHeaderTitle, + PageHeaderTitle } from "../PageHeader/PageHeader" import { Resources } from "../Resources/Resources" import { Stack } from "../Stack/Stack" @@ -16,14 +23,6 @@ import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions" import { WorkspaceDeletedBanner } from "../WorkspaceDeletedBanner/WorkspaceDeletedBanner" import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" -import { AlertBanner } from "../AlertBanner/AlertBanner" -import { - ActiveTransition, - WorkspaceBuildProgress, -} from "components/WorkspaceBuildProgress/WorkspaceBuildProgress" -import { AgentRow } from "components/Resources/AgentRow" -import { Avatar } from "components/Avatar/Avatar" -import { CodeBlock } from "components/CodeBlock/CodeBlock" export enum WorkspaceErrors { GET_BUILDS_ERROR = "getBuildsError", @@ -58,7 +57,6 @@ export interface WorkspaceProps { template?: TypesGen.Template templateParameters?: TypesGen.TemplateVersionParameter[] quota_budget?: number - startupScriptLogs?: TypesGen.StartupScriptLog[] | Error | unknown } /** @@ -86,7 +84,6 @@ export const Workspace: FC> = ({ template, templateParameters, quota_budget, - startupScriptLogs, }) => { const styles = useStyles() const navigate = useNavigate() @@ -214,19 +211,6 @@ export const Workspace: FC> = ({ /> )} - {typeof startupScriptLogs !== "undefined" && - (Array.isArray(startupScriptLogs) ? ( - startupScriptLogs.map((log) => ( - - )) - ) : ( - - ))} - {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( { const { username: usernameQueryParam, workspace: workspaceQueryParam } = @@ -28,10 +27,6 @@ export const WorkspacePage: FC = () => { const { getQuotaError } = quotaState.context const styles = useStyles() - const [startupScriptLogs, setStartupScriptLogs] = useState< - Record | Error | undefined | unknown - >(undefined) - /** * Get workspace, template, and organization on mount and whenever workspaceId changes. * workspaceSend should not change. @@ -46,22 +41,6 @@ export const WorkspacePage: FC = () => { username && quotaSend({ type: "GET_QUOTA", username }) }, [username, quotaSend]) - // Get startup logs once we have agents or when the agents change. - // TODO: Should use xstate? Or that new thing? - // TODO: Does not stream yet. - // TODO: Should maybe add to the existing SSE endpoint instead? - useEffect(() => { - if (workspace?.latest_build) { - API.getStartupScriptLogs(workspace.latest_build.id) - .then((logs) => { - setStartupScriptLogs(logs) - }) - .catch((error) => { - setStartupScriptLogs(error) - }) - } - }, [workspace]) - return ( @@ -97,7 +76,6 @@ export const WorkspacePage: FC = () => { workspaceState={workspaceState} quotaState={quotaState} workspaceSend={workspaceSend} - startupScriptLogs={startupScriptLogs} /> diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 0131f197facf2..b413b5be8cf6a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -30,14 +30,12 @@ interface WorkspaceReadyPageProps { workspaceState: StateFrom quotaState: StateFrom workspaceSend: (event: WorkspaceEvent) => void - startupScriptLogs?: Record | Error } export const WorkspaceReadyPage = ({ workspaceState, quotaState, workspaceSend, - startupScriptLogs, }: WorkspaceReadyPageProps): JSX.Element => { const [_, bannerSend] = useActor( workspaceState.children["scheduleBannerMachine"], @@ -129,7 +127,6 @@ export const WorkspaceReadyPage = ({ template={template} templateParameters={templateParameters} quota_budget={quotaState.context.quota?.budget} - startupScriptLogs={startupScriptLogs} /> API.getWorkspaceAgentStartupLogs(ctx.agentID), + streamStartupLogs: (ctx) => async (callback) => { + return new Promise((resolve, reject) => { + const proto = location.protocol === "https:" ? "wss:" : "ws:" + let after = 0 + if (ctx.startupLogs && ctx.startupLogs.length > 0) { + after = ctx.startupLogs[ctx.startupLogs.length - 1].id + } + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/workspaceagents/${ctx.agentID}/startup-logs?follow&after=${after}`, + ) + socket.binaryType = "blob" + socket.addEventListener("message", (event) => { + callback({ type: "ADD_STARTUP_LOG", log: JSON.parse(event.data) }) + }) + socket.addEventListener("error", () => { + reject(new Error("socket errored")) + }) + socket.addEventListener("open", () => { + resolve() + }) + }) + }, + }, + actions: { + assignStartupLogs: assign({ + startupLogs: (_, { data }) => data, + }), + addStartupLog: assign({ + startupLogs: (context, event) => { + const previousLogs = context.startupLogs ?? [] + return [...previousLogs, event.log] + }, + }), + }, + }, +) From adb06eafffb94f65f70d263ae673d10e9a614b96 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 15 Mar 2023 14:11:34 +0000 Subject: [PATCH 12/29] Improve bulk log performance --- coderd/workspaceagents.go | 48 ++++++------------- site/src/components/Resources/AgentRow.tsx | 20 ++++++++ .../workspaceAgentLogsXService.ts | 14 +++--- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 84f51f2067130..612dd6fd6a413 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -274,7 +274,6 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R CreatedAfter: lowestID - 1, }) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to marshal startup logs notify message", Detail: err.Error(), @@ -374,16 +373,11 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText) defer wsNetConn.Close() // Also closes conn. - logIdsDone := make(map[int64]bool) - // The Go stdlib JSON encoder appends a newline character after message write. encoder := json.NewEncoder(wsNetConn) - for _, provisionerJobLog := range logs { - logIdsDone[provisionerJobLog.ID] = true - err = encoder.Encode(convertWorkspaceAgentStartupLog(provisionerJobLog)) - if err != nil { - return - } + err = encoder.Encode(convertWorkspaceAgentStartupLogs(logs)) + if err != nil { + return } if agent.LifecycleState == database.WorkspaceAgentLifecycleStateReady { // The startup script has finished running, so we can close the connection. @@ -391,15 +385,16 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques } var ( - bufferedLogs = make(chan *database.WorkspaceAgentStartupLog, 128) + // It's not impossible that + bufferedLogs = make(chan []database.WorkspaceAgentStartupLog, 128) endOfLogs atomic.Bool lastSentLogID atomic.Int64 ) - sendLog := func(log *database.WorkspaceAgentStartupLog) { + sendLogs := func(logs []database.WorkspaceAgentStartupLog) { select { - case bufferedLogs <- log: - lastSentLogID.Store(log.ID) + case bufferedLogs <- logs: + lastSentLogID.Store(logs[len(logs)-1].ID) default: logger.Warn(ctx, "workspace agent startup log overflowing channel") } @@ -427,13 +422,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques logger.Warn(ctx, "failed to get workspace agent startup logs after", slog.Error(err)) return } - for _, log := range logs { - if endOfLogs.Load() { - return - } - log := log - sendLog(&log) - } + sendLogs(logs) } if jlMsg.EndOfLogs { @@ -446,10 +435,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques logger.Warn(ctx, "get workspace agent startup logs after", slog.Error(err)) return } - for _, log := range logs { - log := log - sendLog(&log) - } + sendLogs(logs) bufferedLogs <- nil } }, @@ -468,19 +454,15 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques case <-ctx.Done(): logger.Debug(context.Background(), "job logs context canceled") return - case log, ok := <-bufferedLogs: + case logs, ok := <-bufferedLogs: // A nil log is sent when complete! - if !ok || log == nil { + if !ok || logs == nil { logger.Debug(context.Background(), "reached the end of published logs") return } - if logIdsDone[log.ID] { - logger.Debug(ctx, "subscribe duplicated log") - } else { - err = encoder.Encode(convertWorkspaceAgentStartupLog(*log)) - if err != nil { - return - } + err = encoder.Encode(convertWorkspaceAgentStartupLogs(logs)) + if err != nil { + return } } } diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index 801f8b436e03b..dcfa08a160926 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -17,6 +17,8 @@ import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDeskto import { useMachine } from "@xstate/react" import { workspaceAgentLogsMachine } from "xServices/workspaceAgentLogs/workspaceAgentLogsXService" import { Line, Logs } from "components/Logs/Logs" +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" +import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism" export interface AgentRowProps { agent: WorkspaceAgent @@ -45,6 +47,8 @@ export const AgentRow: FC = ({ context: { agentID: agent.id }, }) + console.log("drac", darcula) + return ( = ({
+ Startup Script + + {String(agent.startup_script)} + {logsMachine.context.startupLogs && ( ({ level: "info", @@ -195,4 +209,10 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.text.secondary, marginTop: theme.spacing(0.5), }, + + agentStartupLogs: { + maxHeight: 200, + display: "flex", + flexDirection: "column-reverse", + }, })) diff --git a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts index f70b50f7e4483..08b1d0dc53b85 100644 --- a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts +++ b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts @@ -8,8 +8,8 @@ export const workspaceAgentLogsMachine = createMachine( id: "workspaceAgentLogsMachine", schema: { events: {} as { - type: "ADD_STARTUP_LOG" - log: TypesGen.WorkspaceAgentStartupLog + type: "ADD_STARTUP_LOGS" + logs: TypesGen.WorkspaceAgentStartupLog[] }, context: {} as { agentID: string @@ -45,8 +45,8 @@ export const workspaceAgentLogsMachine = createMachine( }, }, on: { - ADD_STARTUP_LOG: { - actions: "addStartupLog", + ADD_STARTUP_LOGS: { + actions: "addStartupLogs", }, }, }, @@ -65,7 +65,7 @@ export const workspaceAgentLogsMachine = createMachine( ) socket.binaryType = "blob" socket.addEventListener("message", (event) => { - callback({ type: "ADD_STARTUP_LOG", log: JSON.parse(event.data) }) + callback({ type: "ADD_STARTUP_LOGS", logs: JSON.parse(event.data) }) }) socket.addEventListener("error", () => { reject(new Error("socket errored")) @@ -80,10 +80,10 @@ export const workspaceAgentLogsMachine = createMachine( assignStartupLogs: assign({ startupLogs: (_, { data }) => data, }), - addStartupLog: assign({ + addStartupLogs: assign({ startupLogs: (context, event) => { const previousLogs = context.startupLogs ?? [] - return [...previousLogs, event.log] + return [...previousLogs, ...event.logs] }, }), }, From 4061b13240d43e3d436b1bf47803f17051297117 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 15 Mar 2023 17:20:58 +0000 Subject: [PATCH 13/29] Finish UI display --- site/src/components/Logs/Logs.stories.tsx | 8 + site/src/components/Logs/Logs.tsx | 26 ++- site/src/components/Resources/AgentRow.tsx | 194 ++++++++++++++---- .../workspaceAgentLogsXService.ts | 9 +- 4 files changed, 192 insertions(+), 45 deletions(-) diff --git a/site/src/components/Logs/Logs.stories.tsx b/site/src/components/Logs/Logs.stories.tsx index f5d56590f24d4..7f211cc9381e1 100644 --- a/site/src/components/Logs/Logs.stories.tsx +++ b/site/src/components/Logs/Logs.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, Story } from "@storybook/react" +import { LogLevel } from "api/typesGenerated" import { MockWorkspaceBuildLogs } from "../../testHelpers/entities" import { Logs, LogsProps } from "./Logs" @@ -12,8 +13,15 @@ const Template: Story = (args) => const lines = MockWorkspaceBuildLogs.map((log) => ({ time: log.created_at, output: log.output, + level: "info" as LogLevel, })) export const Example = Template.bind({}) Example.args = { lines, } + +export const WithLineNumbers = Template.bind({}) +WithLineNumbers.args = { + lines, + lineNumbers: true, +} diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index b1a101ecb5890..247de9da2931f 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -1,11 +1,11 @@ -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import { LogLevel } from "api/typesGenerated" import dayjs from "dayjs" import { FC } from "react" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { combineClasses } from "../../util/combineClasses" -interface Line { +export interface Line { time: string output: string level: LogLevel @@ -14,15 +14,19 @@ interface Line { export interface LogsProps { lines: Line[] hideTimestamps?: boolean + lineNumbers?: boolean className?: string } export const Logs: FC> = ({ hideTimestamps, lines, + lineNumbers, className = "", }) => { - const styles = useStyles() + const styles = useStyles({ + lineNumbers: Boolean(lineNumbers), + }) return (
@@ -32,7 +36,9 @@ export const Logs: FC> = ({ {!hideTimestamps && ( <> - {dayjs(line.time).format(`HH:mm:ss.SSS`)} + {lineNumbers + ? idx + 1 + : dayjs(line.time).format(`HH:mm:ss.SSS`)}      @@ -45,7 +51,12 @@ export const Logs: FC> = ({ ) } -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles< + Theme, + { + lineNumbers: boolean + } +>((theme) => ({ root: { minHeight: 156, background: theme.palette.background.default, @@ -62,7 +73,7 @@ const useStyles = makeStyles((theme) => ({ }, line: { // Whitespace is significant in terminal output for alignment - whiteSpace: "pre", + whiteSpace: "pre-line", padding: theme.spacing(0, 3), "&.error": { @@ -78,7 +89,8 @@ const useStyles = makeStyles((theme) => ({ }, time: { userSelect: "none", - width: theme.spacing(12.5), + width: ({ lineNumbers }) => theme.spacing(lineNumbers ? 3 : 12.5), + whiteSpace: "pre", display: "inline-block", color: theme.palette.text.secondary, }, diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index dcfa08a160926..5df7e30420464 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -1,24 +1,29 @@ -import { makeStyles } from "@material-ui/core/styles" +import Link from "@material-ui/core/Link" +import Popover from "@material-ui/core/Popover" +import { makeStyles, useTheme } from "@material-ui/core/styles" +import PlayCircleOutlined from "@material-ui/icons/PlayCircleFilledOutlined" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" import { Skeleton } from "@material-ui/lab" +import { useMachine } from "@xstate/react" +import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton" +import { Maybe } from "components/Conditionals/Maybe" +import { Line, Logs } from "components/Logs/Logs" import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" -import { FC } from "react" +import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" +import { FC, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" +import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism" +import { workspaceAgentLogsMachine } from "xServices/workspaceAgentLogs/workspaceAgentLogsXService" import { Workspace, WorkspaceAgent } from "../../api/typesGenerated" import { AppLink } from "../AppLink/AppLink" import { SSHButton } from "../SSHButton/SSHButton" import { Stack } from "../Stack/Stack" import { TerminalLink } from "../TerminalLink/TerminalLink" import { AgentLatency } from "./AgentLatency" -import { AgentVersion } from "./AgentVersion" -import { Maybe } from "components/Conditionals/Maybe" import { AgentStatus } from "./AgentStatus" -import { AppLinkSkeleton } from "components/AppLink/AppLinkSkeleton" -import { useTranslation } from "react-i18next" -import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" -import { useMachine } from "@xstate/react" -import { workspaceAgentLogsMachine } from "xServices/workspaceAgentLogs/workspaceAgentLogsXService" -import { Line, Logs } from "components/Logs/Logs" -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" -import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism" +import { AgentVersion } from "./AgentVersion" export interface AgentRowProps { agent: WorkspaceAgent @@ -43,14 +48,33 @@ export const AgentRow: FC = ({ }) => { const styles = useStyles() const { t } = useTranslation("agent") - const [logsMachine] = useMachine(workspaceAgentLogsMachine, { + const [logsMachine, sendLogsEvent] = useMachine(workspaceAgentLogsMachine, { context: { agentID: agent.id }, }) - - console.log("drac", darcula) + const theme = useTheme() + const startupScriptAnchorRef = useRef(null) + const [startupScriptOpen, setStartupScriptOpen] = useState(false) + const [showStartupLogs, setShowStartupLogs] = useState( + agent.lifecycle_state !== "ready", + ) + useEffect(() => { + setShowStartupLogs(agent.lifecycle_state !== "ready") + }, [agent.lifecycle_state]) + useEffect(() => { + // We only want to fetch logs when they are actually shown, + // otherwise we can make a lot of requests that aren't necessary. + if (showStartupLogs) { + sendLogsEvent("FETCH_STARTUP_LOGS") + } + }, [sendLogsEvent, showStartupLogs]) return ( - + = ({ {t("unableToConnect")} + + + {(logsMachine.context.startupLogs || agent.startup_script) && ( + { + setShowStartupLogs(!showStartupLogs) + }} + > + {showStartupLogs ? ( + + ) : ( + + )} + {showStartupLogs ? "Hide" : "Show"} Startup Logs + + )} + + {agent.startup_script && ( + { + setStartupScriptOpen(!startupScriptOpen) + }} + > + + View Startup Script + + )} + + setStartupScriptOpen(false)} + anchorEl={startupScriptAnchorRef.current} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + > +
+ + {agent.startup_script || ""} + +
+
+
@@ -151,45 +248,68 @@ export const AgentRow: FC = ({ )} - -
- Startup Script - - {String(agent.startup_script)} - - {logsMachine.context.startupLogs && ( - ({ level: "info", output: log.output, time: log.created_at, }), - )} - /> - )} -
+ ) || [] + } + /> + )} ) } const useStyles = makeStyles((theme) => ({ + agentWrapper: { + "&:not(:last-child)": { + borderBottom: `1px solid ${theme.palette.divider}`, + }, + }, + agentRow: { padding: theme.spacing(3, 4), backgroundColor: theme.palette.background.paperLight, fontSize: 16, + }, - "&:not(:last-child)": { - borderBottom: `1px solid ${theme.palette.divider}`, + startupLinks: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + marginTop: theme.spacing(0.5), + }, + + startupLink: { + cursor: "pointer", + display: "flex", + gap: 4, + alignItems: "center", + userSelect: "none", + + "& svg": { + width: 12, + height: 12, }, }, + startupLogs: { + maxHeight: 256, + display: "flex", + flexDirection: "column-reverse", + }, + + startupScriptPopover: { + backgroundColor: theme.palette.background.default, + }, + agentStatusWrapper: { width: theme.spacing(4.5), display: "flex", diff --git a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts index 08b1d0dc53b85..7693ac7e4b001 100644 --- a/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts +++ b/site/src/xServices/workspaceAgentLogs/workspaceAgentLogsXService.ts @@ -10,6 +10,8 @@ export const workspaceAgentLogsMachine = createMachine( events: {} as { type: "ADD_STARTUP_LOGS" logs: TypesGen.WorkspaceAgentStartupLog[] + } | { + type: "FETCH_STARTUP_LOGS", }, context: {} as { agentID: string @@ -22,8 +24,13 @@ export const workspaceAgentLogsMachine = createMachine( }, }, tsTypes: {} as import("./workspaceAgentLogsXService.typegen").Typegen0, - initial: "loading", + initial: "waiting", states: { + waiting: { + on: { + FETCH_STARTUP_LOGS: "loading", + }, + }, loading: { invoke: { src: "getStartupLogs", From 4c5b630131c1f88d6cd1423e50f79d08ecfb0b61 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 15 Mar 2023 17:27:39 +0000 Subject: [PATCH 14/29] Fix startup log visibility --- site/src/components/Resources/AgentRow.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index 5df7e30420464..7891b044d356f 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -54,8 +54,11 @@ export const AgentRow: FC = ({ const theme = useTheme() const startupScriptAnchorRef = useRef(null) const [startupScriptOpen, setStartupScriptOpen] = useState(false) + const hasStartupFeatures = + Boolean(agent.startup_script) || + Boolean(logsMachine.context.startupLogs?.length) const [showStartupLogs, setShowStartupLogs] = useState( - agent.lifecycle_state !== "ready", + agent.lifecycle_state !== "ready" && hasStartupFeatures, ) useEffect(() => { setShowStartupLogs(agent.lifecycle_state !== "ready") @@ -122,7 +125,7 @@ export const AgentRow: FC = ({ spacing={1} className={styles.startupLinks} > - {(logsMachine.context.startupLogs || agent.startup_script) && ( + {hasStartupFeatures && ( Date: Thu, 16 Mar 2023 17:51:36 +0000 Subject: [PATCH 15/29] Add warning for overflow --- agent/agent.go | 7 + agent/agent_test.go | 86 +++++++-- coderd/apidoc/docs.go | 16 +- coderd/apidoc/swagger.json | 16 +- coderd/database/dbauthz/querier.go | 18 ++ coderd/database/dbauthz/querier_test.go | 10 ++ coderd/database/dbfake/databasefake.go | 38 +++- coderd/database/dump.sql | 3 + .../migrations/000109_add_startup_logs.up.sql | 3 + coderd/database/models.go | 2 + coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 37 +++- coderd/database/queries/workspaceagents.sql | 8 + coderd/workspaceagents.go | 34 ++++ coderd/workspaceagents_test.go | 165 ++++++++++++------ codersdk/workspaceagents.go | 53 +++--- docs/api/schemas.md | 19 ++ site/src/components/Resources/AgentRow.tsx | 4 +- 18 files changed, 412 insertions(+), 108 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index ca910f5d5056d..8f312c4814c70 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -677,6 +677,13 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { if err == nil { break } + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) { + if sdkErr.StatusCode() == http.StatusRequestEntityTooLarge { + a.logger.Warn(ctx, "startup logs too large, dropping logs") + break + } + } a.logger.Error(ctx, "upload startup logs", slog.Error(err), slog.F("to_send", logsToSend)) } logMutex.Lock() diff --git a/agent/agent_test.go b/agent/agent_test.go index 3d055856d6778..d163d7bf60dd2 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "net" + "net/http" + "net/http/httptest" "net/netip" "os" "os/exec" @@ -38,6 +40,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" + "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" "github.com/coder/coder/pty/ptytest" @@ -737,25 +740,78 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) { func TestAgent_StartupScript(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("This test doesn't work on Windows for some reason...") - } output := "something" command := "sh -c 'echo " + output + "'" if runtime.GOOS == "windows" { command = "cmd.exe /c echo " + output } - //nolint:dogsled - _, client, _, _, _ := setupAgent(t, agentsdk.Metadata{ - StartupScript: command, - }, 0) - assert.Eventually(t, func() bool { - got := client.getLifecycleStates() - return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady - }, testutil.WaitShort, testutil.IntervalMedium) + t.Run("Success", func(t *testing.T) { + t.Parallel() + client := &client{ + t: t, + agentID: uuid.New(), + metadata: agentsdk.Metadata{ + StartupScript: command, + DERPMap: &tailcfg.DERPMap{}, + }, + statsChan: make(chan *agentsdk.Stats), + coordinator: tailnet.NewCoordinator(), + } + closer := agent.New(agent.Options{ + Client: client, + Filesystem: afero.NewMemMapFs(), + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + ReconnectingPTYTimeout: 0, + }) + t.Cleanup(func() { + _ = closer.Close() + }) + assert.Eventually(t, func() bool { + got := client.getLifecycleStates() + return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady + }, testutil.WaitShort, testutil.IntervalMedium) - require.Len(t, client.getStartupLogs(), 1) - require.Equal(t, output, client.getStartupLogs()[0].Output) + require.Len(t, client.getStartupLogs(), 1) + require.Equal(t, output, client.getStartupLogs()[0].Output) + }) + // This ensures that even when coderd sends back that the startup + // script has written too many lines it will still succeed! + t.Run("OverflowsAndSkips", func(t *testing.T) { + t.Parallel() + client := &client{ + t: t, + agentID: uuid.New(), + metadata: agentsdk.Metadata{ + StartupScript: command, + DERPMap: &tailcfg.DERPMap{}, + }, + patchWorkspaceLogs: func() error { + resp := httptest.NewRecorder() + httpapi.Write(context.Background(), resp, http.StatusRequestEntityTooLarge, codersdk.Response{ + Message: "Too many lines!", + }) + res := resp.Result() + defer res.Body.Close() + return codersdk.ReadBodyAsError(res) + }, + statsChan: make(chan *agentsdk.Stats), + coordinator: tailnet.NewCoordinator(), + } + closer := agent.New(agent.Options{ + Client: client, + Filesystem: afero.NewMemMapFs(), + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + ReconnectingPTYTimeout: 0, + }) + t.Cleanup(func() { + _ = closer.Close() + }) + assert.Eventually(t, func() bool { + got := client.getLifecycleStates() + return len(got) > 0 && got[len(got)-1] == codersdk.WorkspaceAgentLifecycleReady + }, testutil.WaitShort, testutil.IntervalMedium) + require.Len(t, client.getStartupLogs(), 0) + }) } func TestAgent_Lifecycle(t *testing.T) { @@ -1481,6 +1537,7 @@ type client struct { statsChan chan *agentsdk.Stats coordinator tailnet.Coordinator lastWorkspaceAgent func() + patchWorkspaceLogs func() error mu sync.Mutex // Protects following. lifecycleStates []codersdk.WorkspaceAgentLifecycle @@ -1579,6 +1636,9 @@ func (c *client) getStartupLogs() []agentsdk.StartupLog { func (c *client) PatchStartupLogs(_ context.Context, logs agentsdk.PatchStartupLogs) error { c.mu.Lock() defer c.mu.Unlock() + if c.patchWorkspaceLogs != nil { + return c.patchWorkspaceLogs() + } c.logs = append(c.logs, logs.Logs...) return nil } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d3c935e711a83..ae97bcc45815d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4323,10 +4323,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.StartupLog" - } + "$ref": "#/definitions/agentsdk.PatchStartupLogs" } } ], @@ -5338,6 +5335,17 @@ const docTemplate = `{ } } }, + "agentsdk.PatchStartupLogs": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/agentsdk.StartupLog" + } + } + } + }, "agentsdk.PostAppHealthsRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fb38d035c85c8..c7290bda574c7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3795,10 +3795,7 @@ "in": "body", "required": true, "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/agentsdk.StartupLog" - } + "$ref": "#/definitions/agentsdk.PatchStartupLogs" } } ], @@ -4711,6 +4708,17 @@ } } }, + "agentsdk.PatchStartupLogs": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/definitions/agentsdk.StartupLog" + } + } + } + }, "agentsdk.PostAppHealthsRequest": { "type": "object", "properties": { diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index f4e56f910954d..b7f9be6ea5c90 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1237,6 +1237,24 @@ func (q *querier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, ar return q.db.UpdateWorkspaceAgentLifecycleStateByID(ctx, arg) } +func (q *querier) UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg database.UpdateWorkspaceAgentStartupLogOverflowByIDParams) error { + agent, err := q.db.GetWorkspaceAgentByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByAgentID(ctx, agent.ID) + if err != nil { + return err + } + + if err := q.authorizeContext(ctx, rbac.ActionUpdate, workspace); err != nil { + return err + } + + return q.db.UpdateWorkspaceAgentStartupLogOverflowByID(ctx, arg) +} + func (q *querier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg database.UpdateWorkspaceAgentStartupByIDParams) error { agent, err := q.db.GetWorkspaceAgentByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index da88071a9c2cb..a7c6955bdd90f 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -995,6 +995,16 @@ func (s *MethodTestSuite) TestWorkspace() { LifecycleState: database.WorkspaceAgentLifecycleStateCreated, }).Asserts(ws, rbac.ActionUpdate).Returns() })) + s.Run("UpdateWorkspaceAgentStartupLogOverflowByID", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.Workspace{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) + agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + check.Args(database.UpdateWorkspaceAgentStartupLogOverflowByIDParams{ + ID: agt.ID, + StartupLogsOverflowed: true, + }).Asserts(ws, rbac.ActionUpdate).Returns() + })) s.Run("UpdateWorkspaceAgentStartupByID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.Workspace{}) build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 22c2723daab8a..fcb6db0d3e3b8 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -3511,7 +3511,7 @@ func (q *fakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg database.GetWorkspaceAgentStartupLogsAfterParams) ([]database.WorkspaceAgentStartupLog, error) { +func (q *fakeQuerier) GetWorkspaceAgentStartupLogsAfter(_ context.Context, arg database.GetWorkspaceAgentStartupLogsAfterParams) ([]database.WorkspaceAgentStartupLog, error) { if err := validateDatabaseType(arg); err != nil { return nil, err } @@ -3532,7 +3532,7 @@ func (q *fakeQuerier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg return logs, nil } -func (q *fakeQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg database.InsertWorkspaceAgentStartupLogsParams) ([]database.WorkspaceAgentStartupLog, error) { +func (q *fakeQuerier) InsertWorkspaceAgentStartupLogs(_ context.Context, arg database.InsertWorkspaceAgentStartupLogsParams) ([]database.WorkspaceAgentStartupLog, error) { if err := validateDatabaseType(arg); err != nil { return nil, err } @@ -3545,6 +3545,7 @@ func (q *fakeQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg d if len(q.workspaceAgentLogs) > 0 { id = q.workspaceAgentLogs[len(q.workspaceAgentLogs)-1].ID } + outputLength := int32(0) for index, output := range arg.Output { id++ logs = append(logs, database.WorkspaceAgentStartupLog{ @@ -3553,6 +3554,22 @@ func (q *fakeQuerier) InsertWorkspaceAgentStartupLogs(ctx context.Context, arg d CreatedAt: arg.CreatedAt[index], Output: output, }) + outputLength += int32(len(output)) + } + for index, agent := range q.workspaceAgents { + if agent.ID != arg.AgentID { + continue + } + // Greater than 1MB, same as the PostgreSQL constraint! + if agent.StartupLogsLength+outputLength > (1 << 20) { + return nil, &pq.Error{ + Constraint: "max_startup_logs_length", + Table: "workspace_agents", + } + } + agent.StartupLogsLength += outputLength + q.workspaceAgents[index] = agent + break } q.workspaceAgentLogs = append(q.workspaceAgentLogs, logs...) return logs, nil @@ -4703,3 +4720,20 @@ func (q *fakeQuerier) UpdateWorkspaceAgentLifecycleStateByID(_ context.Context, } return sql.ErrNoRows } + +func (q *fakeQuerier) UpdateWorkspaceAgentStartupLogOverflowByID(_ context.Context, arg database.UpdateWorkspaceAgentStartupLogOverflowByIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + for i, agent := range q.workspaceAgents { + if agent.ID == arg.ID { + agent.StartupLogsOverflowed = arg.StartupLogsOverflowed + q.workspaceAgents[i] = agent + return nil + } + } + return sql.ErrNoRows +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9bcb6560486ab..d91cb84434921 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -538,6 +538,7 @@ CREATE TABLE workspace_agents ( shutdown_script character varying(65534), shutdown_script_timeout_seconds integer DEFAULT 0 NOT NULL, startup_logs_length integer DEFAULT 0 NOT NULL, + startup_logs_overflowed boolean DEFAULT false NOT NULL, CONSTRAINT max_startup_logs_length CHECK ((startup_logs_length <= 1048576)) ); @@ -563,6 +564,8 @@ COMMENT ON COLUMN workspace_agents.shutdown_script_timeout_seconds IS 'The numbe COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; +COMMENT ON COLUMN workspace_agents.startup_logs_overflowed IS 'Whether the startup logs overflowed in length'; + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, diff --git a/coderd/database/migrations/000109_add_startup_logs.up.sql b/coderd/database/migrations/000109_add_startup_logs.up.sql index 6ed500c43504b..847358c405f37 100644 --- a/coderd/database/migrations/000109_add_startup_logs.up.sql +++ b/coderd/database/migrations/000109_add_startup_logs.up.sql @@ -8,4 +8,7 @@ CREATE INDEX workspace_agent_startup_logs_id_agent_id_idx ON workspace_agent_sta -- The maximum length of startup logs is 1MB per workspace agent. ALTER TABLE workspace_agents ADD COLUMN startup_logs_length integer NOT NULL DEFAULT 0 CONSTRAINT max_startup_logs_length CHECK (startup_logs_length <= 1048576); +ALTER TABLE workspace_agents ADD COLUMN startup_logs_overflowed boolean NOT NULL DEFAULT false; + COMMENT ON COLUMN workspace_agents.startup_logs_length IS 'Total length of startup logs'; +COMMENT ON COLUMN workspace_agents.startup_logs_overflowed IS 'Whether the startup logs overflowed in length'; diff --git a/coderd/database/models.go b/coderd/database/models.go index afa33006b35bd..e5392e0b38d36 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1569,6 +1569,8 @@ type WorkspaceAgent struct { ShutdownScriptTimeoutSeconds int32 `db:"shutdown_script_timeout_seconds" json:"shutdown_script_timeout_seconds"` // Total length of startup logs StartupLogsLength int32 `db:"startup_logs_length" json:"startup_logs_length"` + // Whether the startup logs overflowed in length + StartupLogsOverflowed bool `db:"startup_logs_overflowed" json:"startup_logs_overflowed"` } type WorkspaceAgentStartupLog struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c31036e18e7c6..79046f005e340 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -226,6 +226,7 @@ type sqlcQuerier interface { UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error + UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentStartupLogOverflowByIDParams) error UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) (WorkspaceBuild, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cc83828e195df..17e006bf2b231 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5046,7 +5046,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP const getWorkspaceAgentByAuthToken = `-- name: GetWorkspaceAgentByAuthToken :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5088,13 +5088,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } const getWorkspaceAgentByID = `-- name: GetWorkspaceAgentByID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5134,13 +5135,14 @@ func (q *sqlQuerier) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (W &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } const getWorkspaceAgentByInstanceID = `-- name: GetWorkspaceAgentByInstanceID :one SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5182,6 +5184,7 @@ func (q *sqlQuerier) GetWorkspaceAgentByInstanceID(ctx context.Context, authInst &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } @@ -5233,7 +5236,7 @@ func (q *sqlQuerier) GetWorkspaceAgentStartupLogsAfter(ctx context.Context, arg const getWorkspaceAgentsByResourceIDs = `-- name: GetWorkspaceAgentsByResourceIDs :many SELECT - id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length + id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE @@ -5279,6 +5282,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, &i.StartupLogsLength, + &i.StartupLogsOverflowed, ); err != nil { return nil, err } @@ -5294,7 +5298,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] } const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many -SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length FROM workspace_agents WHERE created_at > $1 +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed FROM workspace_agents WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { @@ -5336,6 +5340,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, created &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, &i.StartupLogsLength, + &i.StartupLogsOverflowed, ); err != nil { return nil, err } @@ -5376,7 +5381,7 @@ INSERT INTO shutdown_script_timeout_seconds ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, login_before_ready, startup_script_timeout_seconds, expanded_directory, shutdown_script, shutdown_script_timeout_seconds, startup_logs_length, startup_logs_overflowed ` type InsertWorkspaceAgentParams struct { @@ -5458,6 +5463,7 @@ func (q *sqlQuerier) InsertWorkspaceAgent(ctx context.Context, arg InsertWorkspa &i.ShutdownScript, &i.ShutdownScriptTimeoutSeconds, &i.StartupLogsLength, + &i.StartupLogsOverflowed, ) return i, err } @@ -5590,6 +5596,25 @@ func (q *sqlQuerier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg Up return err } +const updateWorkspaceAgentStartupLogOverflowByID = `-- name: UpdateWorkspaceAgentStartupLogOverflowByID :exec +UPDATE + workspace_agents +SET + startup_logs_overflowed = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAgentStartupLogOverflowByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + StartupLogsOverflowed bool `db:"startup_logs_overflowed" json:"startup_logs_overflowed"` +} + +func (q *sqlQuerier) UpdateWorkspaceAgentStartupLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentStartupLogOverflowByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAgentStartupLogOverflowByID, arg.ID, arg.StartupLogsOverflowed) + return err +} + const deleteOldWorkspaceAgentStats = `-- name: DeleteOldWorkspaceAgentStats :exec DELETE FROM workspace_agent_stats WHERE created_at < NOW() - INTERVAL '30 days' ` diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 3b71d1f300c14..995117da511af 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -94,6 +94,14 @@ SET WHERE id = $1; +-- name: UpdateWorkspaceAgentStartupLogOverflowByID :exec +UPDATE + workspace_agents +SET + startup_logs_overflowed = $2 +WHERE + id = $1; + -- name: GetWorkspaceAgentStartupLogsAfter :many SELECT * diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 612dd6fd6a413..757ce61d90aa5 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -255,6 +255,39 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R }) if err != nil { if database.IsStartupLogsLimitError(err) { + if !workspaceAgent.StartupLogsOverflowed { + err := api.Database.UpdateWorkspaceAgentStartupLogOverflowByID(ctx, database.UpdateWorkspaceAgentStartupLogOverflowByIDParams{ + ID: workspaceAgent.ID, + StartupLogsOverflowed: true, + }) + if err != nil { + // We don't want to return here, because the agent will retry + // on failure and this isn't a huge deal. The overflow state + // is just a hint to the user that the logs are incomplete. + api.Logger.Warn(ctx, "failed to update workspace agent startup log overflow", slog.Error(err)) + } + + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get workspace resource.", + Detail: err.Error(), + }) + return + } + + build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Internal error fetching workspace build job.", + Detail: err.Error(), + }) + return + } + + api.publishWorkspaceUpdate(ctx, build.WorkspaceID) + } + httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{ Message: "Startup logs limit exceeded", Detail: err.Error(), @@ -1079,6 +1112,7 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordin Architecture: dbAgent.Architecture, OperatingSystem: dbAgent.OperatingSystem, StartupScript: dbAgent.StartupScript.String, + StartupLogsOverflowed: dbAgent.StartupLogsOverflowed, Version: dbAgent.Version, EnvironmentVariables: envs, Directory: dbAgent.Directory, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index e2a5d3aad0714..490ec083b66a5 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -175,63 +175,126 @@ func TestWorkspaceAgent(t *testing.T) { }) } -func TestWorkspaceAgentStartup(t *testing.T) { +func TestWorkspaceAgentStartupLogs(t *testing.T) { t.Parallel() - ctx, cancelFunc := testutil.Context(t) - defer cancelFunc() - 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{{ - Id: uuid.NewString(), - Auth: &proto.Agent_Token{ - Token: authToken, - }, + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := testutil.Context(t) + defer cancelFunc() + 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{{ + 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) - build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - agentClient := agentsdk.New(client.URL) - agentClient.SetSessionToken(authToken) - err := agentClient.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ - Logs: []agentsdk.StartupLog{{ - CreatedAt: database.Now(), - Output: "testing", - }}, + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err := agentClient.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: []agentsdk.StartupLog{{ + CreatedAt: database.Now(), + Output: "testing", + }}, + }) + require.NoError(t, err) + + logs, closer, err := client.WorkspaceAgentStartupLogsAfter(ctx, build.Resources[0].Agents[0].ID, -500) + require.NoError(t, err) + defer func() { + _ = closer.Close() + }() + var logChunk []codersdk.WorkspaceAgentStartupLog + select { + case <-ctx.Done(): + case logChunk = <-logs: + } + require.NoError(t, ctx.Err()) + require.Len(t, logChunk, 1) + require.Equal(t, "testing", logChunk[0].Output) + cancelFunc() }) - require.NoError(t, err) + t.Run("PublishesOnOverflow", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := testutil.Context(t) + defer cancelFunc() + 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{{ + 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) - logs, closer, err := client.WorkspaceAgentStartupLogsAfter(ctx, build.Resources[0].Agents[0].ID, 0) - require.NoError(t, err) - defer func() { - _ = closer.Close() - }() - var log codersdk.WorkspaceAgentStartupLog - select { - case <-ctx.Done(): - case log = <-logs: - } - require.NoError(t, ctx.Err()) - require.Equal(t, "testing", log.Output) - cancelFunc() + updates, err := client.WatchWorkspace(ctx, workspace.ID) + require.NoError(t, err) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + err = agentClient.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ + Logs: []agentsdk.StartupLog{{ + CreatedAt: database.Now(), + Output: strings.Repeat("a", (1<<20)+1), + }}, + }) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusRequestEntityTooLarge, apiError.StatusCode()) + + var update codersdk.Workspace + select { + case <-ctx.Done(): + t.FailNow() + case update = <-updates: + } + // Ensure that the UI gets an update when the logs overflow! + require.True(t, update.LatestBuild.Resources[0].Agents[0].StartupLogsOverflowed) + cancelFunc() + }) } func TestWorkspaceAgentListen(t *testing.T) { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 35f53a5a8b692..a4f20c0f54e72 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -75,25 +75,26 @@ var WorkspaceAgentLifecycleOrder = []WorkspaceAgentLifecycle{ } type WorkspaceAgent struct { - ID uuid.UUID `json:"id" format:"uuid"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` - FirstConnectedAt *time.Time `json:"first_connected_at,omitempty" format:"date-time"` - LastConnectedAt *time.Time `json:"last_connected_at,omitempty" format:"date-time"` - DisconnectedAt *time.Time `json:"disconnected_at,omitempty" format:"date-time"` - Status WorkspaceAgentStatus `json:"status"` - LifecycleState WorkspaceAgentLifecycle `json:"lifecycle_state"` - Name string `json:"name"` - ResourceID uuid.UUID `json:"resource_id" format:"uuid"` - InstanceID string `json:"instance_id,omitempty"` - Architecture string `json:"architecture"` - EnvironmentVariables map[string]string `json:"environment_variables"` - OperatingSystem string `json:"operating_system"` - StartupScript string `json:"startup_script,omitempty"` - Directory string `json:"directory,omitempty"` - ExpandedDirectory string `json:"expanded_directory,omitempty"` - Version string `json:"version"` - Apps []WorkspaceApp `json:"apps"` + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + FirstConnectedAt *time.Time `json:"first_connected_at,omitempty" format:"date-time"` + LastConnectedAt *time.Time `json:"last_connected_at,omitempty" format:"date-time"` + DisconnectedAt *time.Time `json:"disconnected_at,omitempty" format:"date-time"` + Status WorkspaceAgentStatus `json:"status"` + LifecycleState WorkspaceAgentLifecycle `json:"lifecycle_state"` + Name string `json:"name"` + ResourceID uuid.UUID `json:"resource_id" format:"uuid"` + InstanceID string `json:"instance_id,omitempty"` + Architecture string `json:"architecture"` + EnvironmentVariables map[string]string `json:"environment_variables"` + OperatingSystem string `json:"operating_system"` + StartupScript string `json:"startup_script,omitempty"` + StartupLogsOverflowed bool `json:"startup_logs_overflowed"` + Directory string `json:"directory,omitempty"` + ExpandedDirectory string `json:"expanded_directory,omitempty"` + Version string `json:"version"` + Apps []WorkspaceApp `json:"apps"` // DERPLatency is mapped by region name (e.g. "New York City", "Seattle"). DERPLatency map[string]DERPRegion `json:"latency,omitempty"` ConnectionTimeoutSeconds int32 `json:"connection_timeout_seconds"` @@ -315,7 +316,7 @@ func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid. return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) } -func (c *Client) WorkspaceAgentStartupLogsAfter(ctx context.Context, agentID uuid.UUID, after int64) (<-chan WorkspaceAgentStartupLog, io.Closer, error) { +func (c *Client) WorkspaceAgentStartupLogsAfter(ctx context.Context, agentID uuid.UUID, after int64) (<-chan []WorkspaceAgentStartupLog, io.Closer, error) { afterQuery := "" if after != 0 { afterQuery = fmt.Sprintf("&after=%d", after) @@ -346,28 +347,28 @@ func (c *Client) WorkspaceAgentStartupLogsAfter(ctx context.Context, agentID uui } return nil, nil, ReadBodyAsError(res) } - logs := make(chan WorkspaceAgentStartupLog) + logChunks := make(chan []WorkspaceAgentStartupLog) closed := make(chan struct{}) ctx, wsNetConn := websocketNetConn(ctx, conn, websocket.MessageText) decoder := json.NewDecoder(wsNetConn) go func() { defer close(closed) - defer close(logs) + defer close(logChunks) defer conn.Close(websocket.StatusGoingAway, "") - var log WorkspaceAgentStartupLog + var logs []WorkspaceAgentStartupLog for { - err = decoder.Decode(&log) + err = decoder.Decode(&logs) if err != nil { return } select { case <-ctx.Done(): return - case logs <- log: + case logChunks <- logs: } } }() - return logs, closeFunc(func() error { + return logChunks, closeFunc(func() error { _ = wsNetConn.Close() <-closed return nil diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 369b5d35ebad2..3eafcb2df4ad4 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -200,6 +200,25 @@ | `startup_script_timeout` | integer | false | | | | `vscode_port_proxy_uri` | string | false | | | +## agentsdk.PatchStartupLogs + +```json +{ + "logs": [ + { + "created_at": "string", + "output": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------ | --------------------------------------------------- | -------- | ------------ | ----------- | +| `logs` | array of [agentsdk.StartupLog](#agentsdkstartuplog) | false | | | + ## agentsdk.PostAppHealthsRequest ```json diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index 7891b044d356f..bff9be98737b3 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -61,8 +61,8 @@ export const AgentRow: FC = ({ agent.lifecycle_state !== "ready" && hasStartupFeatures, ) useEffect(() => { - setShowStartupLogs(agent.lifecycle_state !== "ready") - }, [agent.lifecycle_state]) + setShowStartupLogs(agent.lifecycle_state !== "ready" && hasStartupFeatures) + }, [agent.lifecycle_state, hasStartupFeatures]) useEffect(() => { // We only want to fetch logs when they are actually shown, // otherwise we can make a lot of requests that aren't necessary. From 34fde1a88393230fdf6d096f4558ca3d92e2f012 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 17 Mar 2023 20:02:02 +0000 Subject: [PATCH 16/29] Fix agent queue logs overflow --- agent/agent.go | 8 ++--- coderd/apidoc/docs.go | 3 ++ coderd/apidoc/swagger.json | 3 ++ docs/api/agents.md | 1 + docs/api/builds.md | 8 +++++ docs/api/schemas.md | 6 ++++ docs/api/templates.md | 4 +++ docs/api/workspaces.md | 4 +++ site/src/api/typesGenerated.ts | 8 +---- site/src/components/Logs/Logs.tsx | 2 +- site/src/components/Resources/AgentRow.tsx | 35 +++++++++++++--------- 11 files changed, 56 insertions(+), 26 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 8f312c4814c70..5a8f805cbaed2 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -694,12 +694,12 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { logMutex.Lock() defer logMutex.Unlock() queuedLogs = append(queuedLogs, log) - if flushLogsTimer != nil { - flushLogsTimer.Reset(100 * time.Millisecond) + if len(queuedLogs) > 25 { + go sendLogs() return } - if len(queuedLogs) > 100 { - go sendLogs() + if flushLogsTimer != nil { + flushLogsTimer.Reset(100 * time.Millisecond) return } flushLogsTimer = time.AfterFunc(100*time.Millisecond, func() { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ae97bcc45815d..40326fce27b6c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8632,6 +8632,9 @@ const docTemplate = `{ "shutdown_script_timeout_seconds": { "type": "integer" }, + "startup_logs_overflowed": { + "type": "boolean" + }, "startup_script": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c7290bda574c7..9addb222586cb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7777,6 +7777,9 @@ "shutdown_script_timeout_seconds": { "type": "integer" }, + "startup_logs_overflowed": { + "type": "boolean" + }, "startup_script": { "type": "string" }, diff --git a/docs/api/agents.md b/docs/api/agents.md index 6031e8698815b..9188997ab706d 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -519,6 +519,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/docs/api/builds.md b/docs/api/builds.md index d46abaada436d..5ce0bb15adafa 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -106,6 +106,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -256,6 +257,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -547,6 +549,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -627,6 +630,7 @@ Status Code **200** | `»» resource_id` | string(uuid) | false | | | | `»» shutdown_script` | string | false | | | | `»» shutdown_script_timeout_seconds` | integer | false | | | +| `»» startup_logs_overflowed` | boolean | false | | | | `»» startup_script` | string | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | @@ -781,6 +785,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -936,6 +941,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -1050,6 +1056,7 @@ Status Code **200** | `»»» resource_id` | string(uuid) | false | | | | `»»» shutdown_script` | string | false | | | | `»»» shutdown_script_timeout_seconds` | integer | false | | | +| `»»» startup_logs_overflowed` | boolean | false | | | | `»»» startup_script` | string | false | | | | `»»» startup_script_timeout_seconds` | integer | false | | »»startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | @@ -1266,6 +1273,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3eafcb2df4ad4..cea2b6d717da4 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4274,6 +4274,7 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -4399,6 +4400,7 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -4434,6 +4436,7 @@ Parameter represents a set value for the scope. | `resource_id` | string | false | | | | `shutdown_script` | string | false | | | | `shutdown_script_timeout_seconds` | integer | false | | | +| `startup_logs_overflowed` | boolean | false | | | | `startup_script` | string | false | | | | `startup_script_timeout_seconds` | integer | false | | Startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `status` | [codersdk.WorkspaceAgentStatus](#codersdkworkspaceagentstatus) | false | | | @@ -4744,6 +4747,7 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -4963,6 +4967,7 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -5158,6 +5163,7 @@ Parameter represents a set value for the scope. "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/docs/api/templates.md b/docs/api/templates.md index 9e9f6f58a1c36..228e2cac51592 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1624,6 +1624,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -1704,6 +1705,7 @@ Status Code **200** | `»» resource_id` | string(uuid) | false | | | | `»» shutdown_script` | string | false | | | | `»» shutdown_script_timeout_seconds` | integer | false | | | +| `»» startup_logs_overflowed` | boolean | false | | | | `»» startup_script` | string | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | @@ -2049,6 +2051,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -2129,6 +2132,7 @@ Status Code **200** | `»» resource_id` | string(uuid) | false | | | | `»» shutdown_script` | string | false | | | | `»» shutdown_script_timeout_seconds` | integer | false | | | +| `»» startup_logs_overflowed` | boolean | false | | | | `»» startup_script` | string | false | | | | `»» startup_script_timeout_seconds` | integer | false | | »startup script timeout seconds is the number of seconds to wait for the startup script to complete. If the script does not complete within this time, the agent lifecycle will be marked as start_timeout. | | `»» status` | [codersdk.WorkspaceAgentStatus](schemas.md#codersdkworkspaceagentstatus) | false | | | diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index ca19ba4f54dcb..d22a3013c8c5e 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -138,6 +138,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -307,6 +308,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -495,6 +497,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", @@ -665,6 +668,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "shutdown_script": "string", "shutdown_script_timeout_seconds": 0, + "startup_logs_overflowed": true, "startup_script": "string", "startup_script_timeout_seconds": 0, "status": "connecting", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bee49a3d2df10..72ca0975d98ca 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -689,13 +689,6 @@ export interface SessionCountDeploymentStats { readonly reconnecting_pty: number } -// From codersdk/workspacebuilds.go -export interface StartupScriptLog { - readonly agent_id: string - readonly job_id: string - readonly output: string -} - // From codersdk/deployment.go export interface SupportConfig { // Named type "github.com/coder/coder/cli/clibase.Struct[[]github.com/coder/coder/codersdk.LinkConfig]" unknown, using "any" @@ -1015,6 +1008,7 @@ export interface WorkspaceAgent { readonly environment_variables: Record readonly operating_system: string readonly startup_script?: string + readonly startup_logs_overflowed: boolean readonly directory?: string readonly expanded_directory?: string readonly version: string diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index 247de9da2931f..e45c70cf500c1 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -89,7 +89,7 @@ const useStyles = makeStyles< }, time: { userSelect: "none", - width: ({ lineNumbers }) => theme.spacing(lineNumbers ? 3 : 12.5), + width: ({ lineNumbers }) => theme.spacing(lineNumbers ? 3.5 : 12.5), whiteSpace: "pre", display: "inline-block", color: theme.palette.text.secondary, diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index bff9be98737b3..525b58d673db1 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -11,7 +11,7 @@ import { Maybe } from "components/Conditionals/Maybe" import { Line, Logs } from "components/Logs/Logs" import { PortForwardButton } from "components/PortForwardButton/PortForwardButton" import { VSCodeDesktopButton } from "components/VSCodeDesktopButton/VSCodeDesktopButton" -import { FC, useEffect, useRef, useState } from "react" +import { FC, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism" @@ -70,6 +70,24 @@ export const AgentRow: FC = ({ sendLogsEvent("FETCH_STARTUP_LOGS") } }, [sendLogsEvent, showStartupLogs]) + const startupLogs = useMemo(() => { + const logs = + logsMachine.context.startupLogs?.map( + (log): Line => ({ + level: "info", + output: log.output, + time: log.created_at, + }), + ) || [] + if (agent.startup_logs_overflowed) { + logs.push({ + level: "error", + output: "Startup logs exceeded the max size of 1MB!", + time: new Date().toISOString(), + }) + } + return logs + }, [logsMachine.context.startupLogs, agent.startup_logs_overflowed]) return ( = ({ {showStartupLogs && ( - ({ - level: "info", - output: log.output, - time: log.created_at, - }), - ) || [] - } - /> + )} ) @@ -296,6 +302,7 @@ const useStyles = makeStyles((theme) => ({ gap: 4, alignItems: "center", userSelect: "none", + whiteSpace: "nowrap", "& svg": { width: 12, From 379f1f460b28841a32f0dda313424c84501c7df3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 19 Mar 2023 23:19:47 +0000 Subject: [PATCH 17/29] Display staartup logs in a virtual DOM for performance --- agent/agent.go | 34 ++++++-- coderd/workspaceagents.go | 27 +++--- site/package.json | 4 + .../DeploymentBanner/DeploymentBannerView.tsx | 1 - site/src/components/Logs/Logs.tsx | 36 +++++++- site/src/components/Resources/AgentRow.tsx | 82 ++++++++++++++++--- site/src/components/Workspace/Workspace.tsx | 4 +- .../workspaceAgentLogsXService.ts | 49 ++++++++--- site/yarn.lock | 32 ++++++++ 9 files changed, 223 insertions(+), 46 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 5a8f805cbaed2..18bc1f1f19d31 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -649,27 +649,46 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { _ = fileWriter.Close() }() + // Create pipes for startup logs reader and writer startupLogsReader, startupLogsWriter := io.Pipe() + + // Close the pipes when the function returns defer func() { _ = startupLogsReader.Close() _ = startupLogsWriter.Close() }() + + // Create a multi-writer for startup logs and file writer writer := io.MultiWriter(startupLogsWriter, fileWriter) + // Initialize variables for log management queuedLogs := make([]agentsdk.StartupLog, 0) var flushLogsTimer *time.Timer var logMutex sync.Mutex var logsSending bool + + // sendLogs function uploads the queued logs to the server sendLogs := func() { + // Lock logMutex and check if logs are already being sent logMutex.Lock() if logsSending { logMutex.Unlock() return } - logsSending = true + if flushLogsTimer != nil { + flushLogsTimer.Stop() + } + if len(queuedLogs) == 0 { + logMutex.Unlock() + return + } + // Move the current queued logs to logsToSend and clear the queue logsToSend := queuedLogs + logsSending = true queuedLogs = make([]agentsdk.StartupLog, 0) logMutex.Unlock() + + // Retry uploading logs until successful or a specific error occurs for r := retry.New(time.Second, 5*time.Second); r.Wait(ctx); { err := a.client.PatchStartupLogs(ctx, agentsdk.PatchStartupLogs{ Logs: logsToSend, @@ -686,25 +705,30 @@ func (a *agent) runScript(ctx context.Context, lifecycle, script string) error { } a.logger.Error(ctx, "upload startup logs", slog.Error(err), slog.F("to_send", logsToSend)) } + // Reset logsSending flag logMutex.Lock() logsSending = false logMutex.Unlock() } + // queueLog function appends a log to the queue and triggers sendLogs if necessary queueLog := func(log agentsdk.StartupLog) { logMutex.Lock() defer logMutex.Unlock() + + // Append log to the queue queuedLogs = append(queuedLogs, log) - if len(queuedLogs) > 25 { + + // If there are more than 100 logs, send them immediately + if len(queuedLogs) > 100 { go sendLogs() return } + // Reset or set the flushLogsTimer to trigger sendLogs after 100 milliseconds if flushLogsTimer != nil { flushLogsTimer.Reset(100 * time.Millisecond) return } - flushLogsTimer = time.AfterFunc(100*time.Millisecond, func() { - sendLogs() - }) + flushLogsTimer = time.AfterFunc(100*time.Millisecond, sendLogs) } err = a.trackConnGoroutine(func() { scanner := bufio.NewScanner(startupLogsReader) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 757ce61d90aa5..0315f261895f3 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -238,7 +238,12 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R if !httpapi.Read(ctx, rw, r, &req) { return } - + if len(req.Logs) == 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "No logs provided.", + }) + return + } createdAt := make([]time.Time, 0) output := make([]string, 0) outputLength := 0 @@ -342,11 +347,11 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) { // This mostly copies how provisioner job logs are streamed! var ( - ctx = r.Context() - agent = httpmw.WorkspaceAgentParam(r) - logger = api.Logger.With(slog.F("workspace_agent_id", agent.ID)) - follow = r.URL.Query().Has("follow") - afterRaw = r.URL.Query().Get("after") + ctx = r.Context() + workspaceAgent = httpmw.WorkspaceAgentParam(r) + logger = api.Logger.With(slog.F("workspace_agent_id", workspaceAgent.ID)) + follow = r.URL.Query().Has("follow") + afterRaw = r.URL.Query().Get("after") ) var after int64 @@ -366,7 +371,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques } logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ - AgentID: agent.ID, + AgentID: workspaceAgent.ID, CreatedAfter: after, }) if errors.Is(err, sql.ErrNoRows) { @@ -412,7 +417,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques if err != nil { return } - if agent.LifecycleState == database.WorkspaceAgentLifecycleStateReady { + if workspaceAgent.LifecycleState == database.WorkspaceAgentLifecycleStateReady { // The startup script has finished running, so we can close the connection. return } @@ -434,7 +439,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques } closeSubscribe, err := api.Pubsub.Subscribe( - agentsdk.StartupLogsNotifyChannel(agent.ID), + agentsdk.StartupLogsNotifyChannel(workspaceAgent.ID), func(ctx context.Context, message []byte) { if endOfLogs.Load() { return @@ -448,7 +453,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques if jlMsg.CreatedAfter != 0 { logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ - AgentID: agent.ID, + AgentID: workspaceAgent.ID, CreatedAfter: jlMsg.CreatedAfter, }) if err != nil { @@ -461,7 +466,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques if jlMsg.EndOfLogs { endOfLogs.Store(true) logs, err := api.Database.GetWorkspaceAgentStartupLogsAfter(ctx, database.GetWorkspaceAgentStartupLogsAfterParams{ - AgentID: agent.ID, + AgentID: workspaceAgent.ID, CreatedAfter: lastSentLogID.Load(), }) if err != nil { diff --git a/site/package.json b/site/package.json index 21854772d21b5..7ce13027cc3ac 100644 --- a/site/package.json +++ b/site/package.json @@ -73,6 +73,8 @@ "react-markdown": "8.0.3", "react-router-dom": "6.4.1", "react-syntax-highlighter": "15.5.0", + "react-virtualized-auto-sizer": "^1.0.7", + "react-window": "^1.8.8", "remark-gfm": "3.0.1", "rollup-plugin-visualizer": "5.9.0", "sourcemapped-stacktrace": "1.1.11", @@ -102,6 +104,8 @@ "@types/react-dom": "18.0.6", "@types/react-helmet": "6.1.5", "@types/react-syntax-highlighter": "15.5.5", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", "@types/semver": "7.3.12", "@types/ua-parser-js": "0.7.36", "@types/uuid": "8.3.4", diff --git a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx index 38e002b071b3e..0e5dbd9b7a1df 100644 --- a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx +++ b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx @@ -192,7 +192,6 @@ export const DeploymentBannerView: FC = ({
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 039a0c16a66a1..aea797d1f262f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -472,6 +472,13 @@ export const MockWorkspaceAgentStarting: TypesGen.WorkspaceAgent = { lifecycle_state: "starting", } +export const MockWorkspaceAgentReady: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: "test-workspace-agent-ready", + name: "a-ready-workspace-agent", + lifecycle_state: "ready", +} + export const MockWorkspaceAgentStartTimeout: TypesGen.WorkspaceAgent = { ...MockWorkspaceAgent, id: "test-workspace-agent-start-timeout", From c48658c57861954f909e30849e35b047f28785ab Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 23 Mar 2023 04:19:50 +0000 Subject: [PATCH 29/29] Fix startup log wrapping --- agent/agent.go | 2 -- site/src/components/Logs/Logs.tsx | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 35b722b896c0b..e614a28c8905c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -778,9 +778,7 @@ func (a *agent) trackScriptLogs(ctx context.Context, reader io.Reader) (chan str // coming in. logsFinished := make(chan struct{}) err := a.trackConnGoroutine(func() { - buffer := make([]byte, 0, 1<<20) scanner := bufio.NewScanner(reader) - scanner.Buffer(buffer, 1<<20) for scanner.Scan() { queueLog(agentsdk.StartupLog{ CreatedAt: database.Now(), diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index 7e6a55d2dfd71..685be552c24b2 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -99,9 +99,9 @@ const useStyles = makeStyles< wordBreak: "break-all", color: theme.palette.text.primary, fontFamily: MONOSPACE_FONT_FAMILY, - height: logLineHeight, + height: ({ lineNumbers }) => (lineNumbers ? logLineHeight : "auto"), // Whitespace is significant in terminal output for alignment - whiteSpace: "pre-line", + whiteSpace: "pre", padding: theme.spacing(0, 3), "&.error": {