diff --git a/coderd/agentapi/scripts.go b/coderd/agentapi/scripts.go index 3aa085ade8a03..9f5e098e3c721 100644 --- a/coderd/agentapi/scripts.go +++ b/coderd/agentapi/scripts.go @@ -47,7 +47,7 @@ func (s *ScriptsAPI) ScriptCompleted(ctx context.Context, req *agentproto.Worksp //nolint:gocritic // We need permissions to write to the DB here and we are in the context of the agent. ctx = dbauthz.AsProvisionerd(ctx) - err = s.Database.InsertWorkspaceAgentScriptTimings(ctx, database.InsertWorkspaceAgentScriptTimingsParams{ + _, err = s.Database.InsertWorkspaceAgentScriptTimings(ctx, database.InsertWorkspaceAgentScriptTimingsParams{ ScriptID: scriptID, Stage: stage, Status: status, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a185cc66e168c..3578d644b22a0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7417,6 +7417,41 @@ const docTemplate = `{ } } }, + "/workspacebuilds/{workspacebuild}/timings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Builds" + ], + "summary": "Get workspace build timings by ID", + "operationId": "get-workspace-build-timings-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace build ID", + "name": "workspacebuild", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceBuildTimings" + } + } + } + } + }, "/workspaceproxies": { "get": { "security": [ @@ -8450,7 +8485,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.WorkspaceTimings" + "$ref": "#/definitions/codersdk.WorkspaceBuildTimings" } } } @@ -8884,6 +8919,31 @@ const docTemplate = `{ } } }, + "codersdk.AgentScriptTiming": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "exit_code": { + "type": "integer" + }, + "stage": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + } + } + }, "codersdk.AgentSubsystem": { "type": "string", "enum": [ @@ -14596,6 +14656,23 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceBuildTimings": { + "type": "object", + "properties": { + "agent_script_timings": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentScriptTiming" + } + }, + "provisioner_timings": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerTiming" + } + } + } + }, "codersdk.WorkspaceConnectionLatencyMS": { "type": "object", "properties": { @@ -14839,17 +14916,6 @@ const docTemplate = `{ "WorkspaceStatusDeleted" ] }, - "codersdk.WorkspaceTimings": { - "type": "object", - "properties": { - "provisioner_timings": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.ProvisionerTiming" - } - } - } - }, "codersdk.WorkspaceTransition": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 84d1b67760f88..2555abb536587 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6561,6 +6561,37 @@ } } }, + "/workspacebuilds/{workspacebuild}/timings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Builds"], + "summary": "Get workspace build timings by ID", + "operationId": "get-workspace-build-timings-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace build ID", + "name": "workspacebuild", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceBuildTimings" + } + } + } + } + }, "/workspaceproxies": { "get": { "security": [ @@ -7476,7 +7507,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.WorkspaceTimings" + "$ref": "#/definitions/codersdk.WorkspaceBuildTimings" } } } @@ -7874,6 +7905,31 @@ } } }, + "codersdk.AgentScriptTiming": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "exit_code": { + "type": "integer" + }, + "stage": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + } + } + }, "codersdk.AgentSubsystem": { "type": "string", "enum": ["envbox", "envbuilder", "exectrace"], @@ -13285,6 +13341,23 @@ } } }, + "codersdk.WorkspaceBuildTimings": { + "type": "object", + "properties": { + "agent_script_timings": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentScriptTiming" + } + }, + "provisioner_timings": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ProvisionerTiming" + } + } + } + }, "codersdk.WorkspaceConnectionLatencyMS": { "type": "object", "properties": { @@ -13524,17 +13597,6 @@ "WorkspaceStatusDeleted" ] }, - "codersdk.WorkspaceTimings": { - "type": "object", - "properties": { - "provisioner_timings": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.ProvisionerTiming" - } - } - } - }, "codersdk.WorkspaceTransition": { "type": "string", "enum": ["start", "stop", "delete"], diff --git a/coderd/coderd.go b/coderd/coderd.go index cbe008a726636..80b607bda9ba4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1165,6 +1165,7 @@ func New(options *Options) *API { r.Get("/parameters", api.workspaceBuildParameters) r.Get("/resources", api.workspaceBuildResourcesDeprecated) r.Get("/state", api.workspaceBuildState) + r.Get("/timings", api.workspaceBuildTimings) }) r.Route("/authcheck", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6436e7c6e3425..25d0c94999948 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2421,6 +2421,13 @@ func (q *querier) GetWorkspaceAgentPortShare(ctx context.Context, arg database.G return q.db.GetWorkspaceAgentPortShare(ctx, arg) } +func (q *querier) GetWorkspaceAgentScriptTimingsByBuildID(ctx context.Context, id uuid.UUID) ([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetWorkspaceAgentScriptTimingsByBuildID(ctx, id) +} + func (q *querier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return nil, err @@ -3034,9 +3041,9 @@ func (q *querier) InsertWorkspaceAgentMetadata(ctx context.Context, arg database return q.db.InsertWorkspaceAgentMetadata(ctx, arg) } -func (q *querier) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg database.InsertWorkspaceAgentScriptTimingsParams) error { +func (q *querier) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg database.InsertWorkspaceAgentScriptTimingsParams) (database.WorkspaceAgentScriptTiming, error) { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { - return err + return database.WorkspaceAgentScriptTiming{}, err } return q.db.InsertWorkspaceAgentScriptTimings(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index f3aec6c9326b0..c431f4d675525 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -551,26 +551,6 @@ func (s *MethodTestSuite) TestProvisionerJob() { check.Args(database.UpdateProvisionerJobWithCancelByIDParams{ID: j.ID}). Asserts(v.RBACObject(tpl), []policy.Action{policy.ActionRead, policy.ActionUpdate}).Returns() })) - s.Run("GetProvisionerJobTimingsByJobID", s.Subtest(func(db database.Store, check *expects) { - w := dbgen.Workspace(s.T(), db, database.Workspace{}) - j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ - Type: database.ProvisionerJobTypeWorkspaceBuild, - }) - _ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: w.ID}) - t := dbgen.ProvisionerJobTimings(s.T(), db, database.InsertProvisionerJobTimingsParams{ - JobID: j.ID, - StartedAt: []time.Time{dbtime.Now(), dbtime.Now()}, - EndedAt: []time.Time{dbtime.Now(), dbtime.Now()}, - Stage: []database.ProvisionerJobTimingStage{ - database.ProvisionerJobTimingStageInit, - database.ProvisionerJobTimingStagePlan, - }, - Source: []string{"source1", "source2"}, - Action: []string{"action1", "action2"}, - Resource: []string{"resource1", "resource2"}, - }) - check.Args(j.ID).Asserts(w, policy.ActionRead).Returns(t) - })) s.Run("GetProvisionerJobsByIDs", s.Subtest(func(db database.Store, check *expects) { a := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) b := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) @@ -2861,6 +2841,46 @@ func (s *MethodTestSuite) TestSystemFunctions() { LastGeneratedAt: dbtime.Now(), }).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) + s.Run("GetProvisionerJobTimingsByJobID", s.Subtest(func(db database.Store, check *expects) { + w := dbgen.Workspace(s.T(), db, database.Workspace{}) + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: j.ID, WorkspaceID: w.ID}) + t := dbgen.ProvisionerJobTimings(s.T(), db, b, 2) + check.Args(j.ID).Asserts(w, policy.ActionRead).Returns(t) + })) + s.Run("GetWorkspaceAgentScriptTimingsByBuildID", s.Subtest(func(db database.Store, check *expects) { + workspace := dbgen.Workspace(s.T(), db, database.Workspace{}) + job := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{JobID: job.ID, WorkspaceID: workspace.ID}) + resource := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{ + JobID: build.JobID, + }) + agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + script := dbgen.WorkspaceAgentScript(s.T(), db, database.WorkspaceAgentScript{ + WorkspaceAgentID: agent.ID, + }) + timing := dbgen.WorkspaceAgentScriptTiming(s.T(), db, database.WorkspaceAgentScriptTiming{ + ScriptID: script.ID, + }) + rows := []database.GetWorkspaceAgentScriptTimingsByBuildIDRow{ + { + StartedAt: timing.StartedAt, + EndedAt: timing.EndedAt, + Stage: timing.Stage, + ScriptID: timing.ScriptID, + ExitCode: timing.ExitCode, + Status: timing.Status, + DisplayName: script.DisplayName, + }, + } + check.Args(build.ID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(rows) + })) } func (s *MethodTestSuite) TestNotifications() { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1a2f052a279b3..1031b51f72313 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -189,6 +189,49 @@ func WorkspaceAgent(t testing.TB, db database.Store, orig database.WorkspaceAgen return agt } +func WorkspaceAgentScript(t testing.TB, db database.Store, orig database.WorkspaceAgentScript) database.WorkspaceAgentScript { + scripts, err := db.InsertWorkspaceAgentScripts(genCtx, database.InsertWorkspaceAgentScriptsParams{ + WorkspaceAgentID: takeFirst(orig.WorkspaceAgentID, uuid.New()), + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + LogSourceID: []uuid.UUID{takeFirst(orig.LogSourceID, uuid.New())}, + LogPath: []string{takeFirst(orig.LogPath, "")}, + Script: []string{takeFirst(orig.Script, "")}, + Cron: []string{takeFirst(orig.Cron, "")}, + StartBlocksLogin: []bool{takeFirst(orig.StartBlocksLogin, false)}, + RunOnStart: []bool{takeFirst(orig.RunOnStart, false)}, + RunOnStop: []bool{takeFirst(orig.RunOnStop, false)}, + TimeoutSeconds: []int32{takeFirst(orig.TimeoutSeconds, 0)}, + DisplayName: []string{takeFirst(orig.DisplayName, "")}, + ID: []uuid.UUID{takeFirst(orig.ID, uuid.New())}, + }) + require.NoError(t, err, "insert workspace agent script") + require.NotEmpty(t, scripts, "insert workspace agent script returned no scripts") + return scripts[0] +} + +func WorkspaceAgentScriptTimings(t testing.TB, db database.Store, script database.WorkspaceAgentScript, count int) []database.WorkspaceAgentScriptTiming { + timings := make([]database.WorkspaceAgentScriptTiming, count) + for i := range count { + timings[i] = WorkspaceAgentScriptTiming(t, db, database.WorkspaceAgentScriptTiming{ + ScriptID: script.ID, + }) + } + return timings +} + +func WorkspaceAgentScriptTiming(t testing.TB, db database.Store, orig database.WorkspaceAgentScriptTiming) database.WorkspaceAgentScriptTiming { + timing, err := db.InsertWorkspaceAgentScriptTimings(genCtx, database.InsertWorkspaceAgentScriptTimingsParams{ + StartedAt: takeFirst(orig.StartedAt, dbtime.Now()), + EndedAt: takeFirst(orig.EndedAt, dbtime.Now()), + Stage: takeFirst(orig.Stage, database.WorkspaceAgentScriptTimingStageStart), + ScriptID: takeFirst(orig.ScriptID, uuid.New()), + ExitCode: takeFirst(orig.ExitCode, 0), + Status: takeFirst(orig.Status, database.WorkspaceAgentScriptTimingStatusOk), + }) + require.NoError(t, err, "insert workspace agent script") + return timing +} + func Workspace(t testing.TB, db database.Store, orig database.Workspace) database.Workspace { t.Helper() @@ -935,12 +978,30 @@ func CryptoKey(t testing.TB, db database.Store, seed database.CryptoKey) databas return key } -func ProvisionerJobTimings(t testing.TB, db database.Store, seed database.InsertProvisionerJobTimingsParams) []database.ProvisionerJobTiming { - timings, err := db.InsertProvisionerJobTimings(genCtx, seed) - require.NoError(t, err, "insert provisioner job timings") +func ProvisionerJobTimings(t testing.TB, db database.Store, build database.WorkspaceBuild, count int) []database.ProvisionerJobTiming { + timings := make([]database.ProvisionerJobTiming, count) + for i := range count { + timings[i] = provisionerJobTiming(t, db, database.ProvisionerJobTiming{ + JobID: build.JobID, + }) + } return timings } +func provisionerJobTiming(t testing.TB, db database.Store, seed database.ProvisionerJobTiming) database.ProvisionerJobTiming { + timing, err := db.InsertProvisionerJobTimings(genCtx, database.InsertProvisionerJobTimingsParams{ + JobID: takeFirst(seed.JobID, uuid.New()), + StartedAt: []time.Time{takeFirst(seed.StartedAt, dbtime.Now())}, + EndedAt: []time.Time{takeFirst(seed.EndedAt, dbtime.Now())}, + Stage: []database.ProvisionerJobTimingStage{takeFirst(seed.Stage, database.ProvisionerJobTimingStageInit)}, + Source: []string{takeFirst(seed.Source, "source")}, + Action: []string{takeFirst(seed.Action, "action")}, + Resource: []string{takeFirst(seed.Resource, "resource")}, + }) + require.NoError(t, err, "insert provisioner job timing") + return timing[0] +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 09dfa3e7306db..006a02e39dc6f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5793,6 +5793,69 @@ func (q *FakeQuerier) GetWorkspaceAgentPortShare(_ context.Context, arg database return database.WorkspaceAgentPortShare{}, sql.ErrNoRows } +func (q *FakeQuerier) GetWorkspaceAgentScriptTimingsByBuildID(ctx context.Context, id uuid.UUID) ([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + build, err := q.GetWorkspaceBuildByID(ctx, id) + if err != nil { + return nil, xerrors.Errorf("get build: %w", err) + } + + resources, err := q.GetWorkspaceResourcesByJobID(ctx, build.JobID) + if err != nil { + return nil, xerrors.Errorf("get resources: %w", err) + } + resourceIDs := make([]uuid.UUID, 0, len(resources)) + for _, res := range resources { + resourceIDs = append(resourceIDs, res.ID) + } + + agents, err := q.GetWorkspaceAgentsByResourceIDs(ctx, resourceIDs) + if err != nil { + return nil, xerrors.Errorf("get agents: %w", err) + } + agentIDs := make([]uuid.UUID, 0, len(agents)) + for _, agent := range agents { + agentIDs = append(agentIDs, agent.ID) + } + + scripts, err := q.GetWorkspaceAgentScriptsByAgentIDs(ctx, agentIDs) + if err != nil { + return nil, xerrors.Errorf("get scripts: %w", err) + } + scriptIDs := make([]uuid.UUID, 0, len(scripts)) + for _, script := range scripts { + scriptIDs = append(scriptIDs, script.ID) + } + + rows := []database.GetWorkspaceAgentScriptTimingsByBuildIDRow{} + for _, t := range q.workspaceAgentScriptTimings { + if !slice.Contains(scriptIDs, t.ScriptID) { + continue + } + + var script database.WorkspaceAgentScript + for _, s := range scripts { + if s.ID == t.ScriptID { + script = s + break + } + } + + rows = append(rows, database.GetWorkspaceAgentScriptTimingsByBuildIDRow{ + ScriptID: t.ScriptID, + StartedAt: t.StartedAt, + EndedAt: t.EndedAt, + ExitCode: t.ExitCode, + Stage: t.Stage, + Status: t.Status, + DisplayName: script.DisplayName, + }) + } + return rows, nil +} + func (q *FakeQuerier) GetWorkspaceAgentScriptsByAgentIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -7844,28 +7907,19 @@ func (q *FakeQuerier) InsertWorkspaceAgentMetadata(_ context.Context, arg databa return nil } -func (q *FakeQuerier) InsertWorkspaceAgentScriptTimings(_ context.Context, arg database.InsertWorkspaceAgentScriptTimingsParams) error { +func (q *FakeQuerier) InsertWorkspaceAgentScriptTimings(_ context.Context, arg database.InsertWorkspaceAgentScriptTimingsParams) (database.WorkspaceAgentScriptTiming, error) { err := validateDatabaseType(arg) if err != nil { - return err + return database.WorkspaceAgentScriptTiming{}, err } q.mutex.Lock() defer q.mutex.Unlock() - q.workspaceAgentScriptTimings = append(q.workspaceAgentScriptTimings, - //nolint:gosimple // Stop the linter complaining about changing the type of `arg`. - database.WorkspaceAgentScriptTiming{ - ScriptID: arg.ScriptID, - StartedAt: arg.StartedAt, - EndedAt: arg.EndedAt, - ExitCode: arg.ExitCode, - Stage: arg.Stage, - Status: arg.Status, - }, - ) + timing := database.WorkspaceAgentScriptTiming(arg) + q.workspaceAgentScriptTimings = append(q.workspaceAgentScriptTimings, timing) - return nil + return timing, nil } func (q *FakeQuerier) InsertWorkspaceAgentScripts(_ context.Context, arg database.InsertWorkspaceAgentScriptsParams) ([]database.WorkspaceAgentScript, error) { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index b050a4ce9afc4..ec607d1bf52bf 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1404,6 +1404,13 @@ func (m metricsStore) GetWorkspaceAgentPortShare(ctx context.Context, arg databa return r0, r1 } +func (m metricsStore) GetWorkspaceAgentScriptTimingsByBuildID(ctx context.Context, id uuid.UUID) ([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceAgentScriptTimingsByBuildID(ctx, id) + m.queryLatencies.WithLabelValues("GetWorkspaceAgentScriptTimingsByBuildID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAgentScript, error) { start := time.Now() r0, r1 := m.s.GetWorkspaceAgentScriptsByAgentIDs(ctx, ids) @@ -1936,11 +1943,11 @@ func (m metricsStore) InsertWorkspaceAgentMetadata(ctx context.Context, arg data return err } -func (m metricsStore) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg database.InsertWorkspaceAgentScriptTimingsParams) error { +func (m metricsStore) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg database.InsertWorkspaceAgentScriptTimingsParams) (database.WorkspaceAgentScriptTiming, error) { start := time.Now() - err := m.s.InsertWorkspaceAgentScriptTimings(ctx, arg) + r0, r1 := m.s.InsertWorkspaceAgentScriptTimings(ctx, arg) m.queryLatencies.WithLabelValues("InsertWorkspaceAgentScriptTimings").Observe(time.Since(start).Seconds()) - return err + return r0, r1 } func (m metricsStore) InsertWorkspaceAgentScripts(ctx context.Context, arg database.InsertWorkspaceAgentScriptsParams) ([]database.WorkspaceAgentScript, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 3c7dbd6d9b958..747f6acd16eb3 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2933,6 +2933,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAgentPortShare(arg0, arg1 any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentPortShare), arg0, arg1) } +// GetWorkspaceAgentScriptTimingsByBuildID mocks base method. +func (m *MockStore) GetWorkspaceAgentScriptTimingsByBuildID(arg0 context.Context, arg1 uuid.UUID) ([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceAgentScriptTimingsByBuildID", arg0, arg1) + ret0, _ := ret[0].([]database.GetWorkspaceAgentScriptTimingsByBuildIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceAgentScriptTimingsByBuildID indicates an expected call of GetWorkspaceAgentScriptTimingsByBuildID. +func (mr *MockStoreMockRecorder) GetWorkspaceAgentScriptTimingsByBuildID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAgentScriptTimingsByBuildID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAgentScriptTimingsByBuildID), arg0, arg1) +} + // GetWorkspaceAgentScriptsByAgentIDs mocks base method. func (m *MockStore) GetWorkspaceAgentScriptsByAgentIDs(arg0 context.Context, arg1 []uuid.UUID) ([]database.WorkspaceAgentScript, error) { m.ctrl.T.Helper() @@ -4080,11 +4095,12 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAgentMetadata(arg0, arg1 any) *g } // InsertWorkspaceAgentScriptTimings mocks base method. -func (m *MockStore) InsertWorkspaceAgentScriptTimings(arg0 context.Context, arg1 database.InsertWorkspaceAgentScriptTimingsParams) error { +func (m *MockStore) InsertWorkspaceAgentScriptTimings(arg0 context.Context, arg1 database.InsertWorkspaceAgentScriptTimingsParams) (database.WorkspaceAgentScriptTiming, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertWorkspaceAgentScriptTimings", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(database.WorkspaceAgentScriptTiming) + ret1, _ := ret[1].(error) + return ret0, ret1 } // InsertWorkspaceAgentScriptTimings indicates an expected call of InsertWorkspaceAgentScriptTimings. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d71c54e008350..2a93c6d2c4ede 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -298,6 +298,7 @@ type sqlcQuerier interface { GetWorkspaceAgentLogsAfter(ctx context.Context, arg GetWorkspaceAgentLogsAfterParams) ([]WorkspaceAgentLog, error) GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorkspaceAgentMetadataParams) ([]WorkspaceAgentMetadatum, error) GetWorkspaceAgentPortShare(ctx context.Context, arg GetWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) + GetWorkspaceAgentScriptTimingsByBuildID(ctx context.Context, id uuid.UUID) ([]GetWorkspaceAgentScriptTimingsByBuildIDRow, error) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) GetWorkspaceAgentStats(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsRow, error) GetWorkspaceAgentStatsAndLabels(ctx context.Context, createdAt time.Time) ([]GetWorkspaceAgentStatsAndLabelsRow, error) @@ -395,7 +396,7 @@ type sqlcQuerier interface { InsertWorkspaceAgentLogSources(ctx context.Context, arg InsertWorkspaceAgentLogSourcesParams) ([]WorkspaceAgentLogSource, error) InsertWorkspaceAgentLogs(ctx context.Context, arg InsertWorkspaceAgentLogsParams) ([]WorkspaceAgentLog, error) InsertWorkspaceAgentMetadata(ctx context.Context, arg InsertWorkspaceAgentMetadataParams) error - InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) error + InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) (WorkspaceAgentScriptTiming, error) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f5b2943d1fa04..4d3837b9493ec 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11420,6 +11420,57 @@ func (q *sqlQuerier) GetWorkspaceAgentMetadata(ctx context.Context, arg GetWorks return items, nil } +const getWorkspaceAgentScriptTimingsByBuildID = `-- name: GetWorkspaceAgentScriptTimingsByBuildID :many +SELECT workspace_agent_script_timings.script_id, workspace_agent_script_timings.started_at, workspace_agent_script_timings.ended_at, workspace_agent_script_timings.exit_code, workspace_agent_script_timings.stage, workspace_agent_script_timings.status, workspace_agent_scripts.display_name +FROM workspace_agent_script_timings +INNER JOIN workspace_agent_scripts ON workspace_agent_scripts.id = workspace_agent_script_timings.script_id +INNER JOIN workspace_agents ON workspace_agents.id = workspace_agent_scripts.workspace_agent_id +INNER JOIN workspace_resources ON workspace_resources.id = workspace_agents.resource_id +INNER JOIN workspace_builds ON workspace_builds.job_id = workspace_resources.job_id +WHERE workspace_builds.id = $1 +` + +type GetWorkspaceAgentScriptTimingsByBuildIDRow struct { + ScriptID uuid.UUID `db:"script_id" json:"script_id"` + StartedAt time.Time `db:"started_at" json:"started_at"` + EndedAt time.Time `db:"ended_at" json:"ended_at"` + ExitCode int32 `db:"exit_code" json:"exit_code"` + Stage WorkspaceAgentScriptTimingStage `db:"stage" json:"stage"` + Status WorkspaceAgentScriptTimingStatus `db:"status" json:"status"` + DisplayName string `db:"display_name" json:"display_name"` +} + +func (q *sqlQuerier) GetWorkspaceAgentScriptTimingsByBuildID(ctx context.Context, id uuid.UUID) ([]GetWorkspaceAgentScriptTimingsByBuildIDRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentScriptTimingsByBuildID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWorkspaceAgentScriptTimingsByBuildIDRow + for rows.Next() { + var i GetWorkspaceAgentScriptTimingsByBuildIDRow + if err := rows.Scan( + &i.ScriptID, + &i.StartedAt, + &i.EndedAt, + &i.ExitCode, + &i.Stage, + &i.Status, + &i.DisplayName, + ); 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, instance_metadata, resource_metadata, directory, version, last_connected_replica_id, connection_timeout_seconds, troubleshooting_url, motd_file, lifecycle_state, expanded_directory, logs_length, logs_overflowed, started_at, ready_at, subsystems, display_apps, api_version, display_order @@ -11879,7 +11930,7 @@ func (q *sqlQuerier) InsertWorkspaceAgentMetadata(ctx context.Context, arg Inser return err } -const insertWorkspaceAgentScriptTimings = `-- name: InsertWorkspaceAgentScriptTimings :exec +const insertWorkspaceAgentScriptTimings = `-- name: InsertWorkspaceAgentScriptTimings :one INSERT INTO workspace_agent_script_timings ( script_id, @@ -11891,6 +11942,7 @@ INSERT INTO ) VALUES ($1, $2, $3, $4, $5, $6) +RETURNING workspace_agent_script_timings.script_id, workspace_agent_script_timings.started_at, workspace_agent_script_timings.ended_at, workspace_agent_script_timings.exit_code, workspace_agent_script_timings.stage, workspace_agent_script_timings.status ` type InsertWorkspaceAgentScriptTimingsParams struct { @@ -11902,8 +11954,8 @@ type InsertWorkspaceAgentScriptTimingsParams struct { Status WorkspaceAgentScriptTimingStatus `db:"status" json:"status"` } -func (q *sqlQuerier) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) error { - _, err := q.db.ExecContext(ctx, insertWorkspaceAgentScriptTimings, +func (q *sqlQuerier) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg InsertWorkspaceAgentScriptTimingsParams) (WorkspaceAgentScriptTiming, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAgentScriptTimings, arg.ScriptID, arg.StartedAt, arg.EndedAt, @@ -11911,7 +11963,16 @@ func (q *sqlQuerier) InsertWorkspaceAgentScriptTimings(ctx context.Context, arg arg.Stage, arg.Status, ) - return err + var i WorkspaceAgentScriptTiming + err := row.Scan( + &i.ScriptID, + &i.StartedAt, + &i.EndedAt, + &i.ExitCode, + &i.Stage, + &i.Status, + ) + return i, err } const updateWorkspaceAgentConnectionByID = `-- name: UpdateWorkspaceAgentConnectionByID :exec diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index 1020aba219920..2c26740db1d88 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -288,7 +288,7 @@ WHERE ) ; --- name: InsertWorkspaceAgentScriptTimings :exec +-- name: InsertWorkspaceAgentScriptTimings :one INSERT INTO workspace_agent_script_timings ( script_id, @@ -299,4 +299,14 @@ INSERT INTO status ) VALUES - ($1, $2, $3, $4, $5, $6); + ($1, $2, $3, $4, $5, $6) +RETURNING workspace_agent_script_timings.*; + +-- name: GetWorkspaceAgentScriptTimingsByBuildID :many +SELECT workspace_agent_script_timings.*, workspace_agent_scripts.display_name +FROM workspace_agent_script_timings +INNER JOIN workspace_agent_scripts ON workspace_agent_scripts.id = workspace_agent_script_timings.script_id +INNER JOIN workspace_agents ON workspace_agents.id = workspace_agent_scripts.workspace_agent_id +INNER JOIN workspace_resources ON workspace_resources.id = workspace_agents.resource_id +INNER JOIN workspace_builds ON workspace_builds.job_id = workspace_resources.job_id +WHERE workspace_builds.id = $1; \ No newline at end of file diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index e04e585d4aa53..92e21b78e0756 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -647,6 +647,32 @@ func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) { _, _ = rw.Write(workspaceBuild.ProvisionerState) } +// @Summary Get workspace build timings by ID +// @ID get-workspace-build-timings-by-id +// @Security CoderSessionToken +// @Produce json +// @Tags Builds +// @Param workspacebuild path string true "Workspace build ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceBuildTimings +// @Router /workspacebuilds/{workspacebuild}/timings [get] +func (api *API) workspaceBuildTimings(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + build = httpmw.WorkspaceBuildParam(r) + ) + + timings, err := api.buildTimings(ctx, build) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching timings.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, timings) +} + type workspaceBuildsData struct { users []database.User jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow @@ -1010,3 +1036,44 @@ func convertWorkspaceStatus(jobStatus codersdk.ProvisionerJobStatus, transition // return error status since we should never get here return codersdk.WorkspaceStatusFailed } + +func (api *API) buildTimings(ctx context.Context, build database.WorkspaceBuild) (codersdk.WorkspaceBuildTimings, error) { + provisionerTimings, err := api.Database.GetProvisionerJobTimingsByJobID(ctx, build.JobID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching provisioner job timings: %w", err) + } + + agentScriptTimings, err := api.Database.GetWorkspaceAgentScriptTimingsByBuildID(ctx, build.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace agent script timings: %w", err) + } + + res := codersdk.WorkspaceBuildTimings{ + ProvisionerTimings: make([]codersdk.ProvisionerTiming, 0, len(provisionerTimings)), + AgentScriptTimings: make([]codersdk.AgentScriptTiming, 0, len(agentScriptTimings)), + } + + for _, t := range provisionerTimings { + res.ProvisionerTimings = append(res.ProvisionerTimings, codersdk.ProvisionerTiming{ + JobID: t.JobID, + Stage: string(t.Stage), + Source: t.Source, + Action: t.Action, + Resource: t.Resource, + StartedAt: t.StartedAt, + EndedAt: t.EndedAt, + }) + } + for _, t := range agentScriptTimings { + res.AgentScriptTimings = append(res.AgentScriptTimings, codersdk.AgentScriptTiming{ + StartedAt: t.StartedAt, + EndedAt: t.EndedAt, + ExitCode: t.ExitCode, + Stage: string(t.Stage), + Status: string(t.Status), + DisplayName: t.DisplayName, + }) + } + + return res, nil +} diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 757dac7fb6326..580b01fdec1a9 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -23,6 +23,8 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/rbac" @@ -40,7 +42,7 @@ func TestWorkspaceBuild(t *testing.T) { propagation.Baggage{}, ), ) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := testutil.Context(t, testutil.WaitLong) auditor := audit.NewMock() client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ IncludeProvisionerDaemon: true, @@ -1180,3 +1182,188 @@ func TestPostWorkspaceBuild(t *testing.T) { require.Len(t, res.Workspaces, 0) }) } + +func TestWorkspaceBuildTimings(t *testing.T) { + t.Parallel() + + // Setup the test environment with a template and version + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + owner := coderdtest.CreateFirstUser(t, client) + file := dbgen.File(t, db, database.File{ + CreatedBy: owner.UserID, + }) + versionJob := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: owner.OrganizationID, + InitiatorID: owner.UserID, + FileID: file.ID, + Tags: database.StringMap{ + "custom": "true", + }, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + JobID: versionJob.ID, + CreatedBy: owner.UserID, + }) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: owner.OrganizationID, + ActiveVersionID: version.ID, + CreatedBy: owner.UserID, + }) + ws := dbgen.Workspace(t, db, database.Workspace{ + OwnerID: owner.UserID, + OrganizationID: owner.OrganizationID, + TemplateID: template.ID, + }) + + // Create a build to attach timings + var buildNumber int32 + makeBuild := func() database.WorkspaceBuild { + buildNumber++ + jobID := uuid.New() + job := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + ID: jobID, + OrganizationID: owner.OrganizationID, + Tags: database.StringMap{jobID.String(): "true"}, + }) + return dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + TemplateVersionID: version.ID, + InitiatorID: owner.UserID, + JobID: job.ID, + BuildNumber: buildNumber, + }) + } + + t.Run("NonExistentBuild", func(t *testing.T) { + t.Parallel() + + // When: fetching an inexistent build + buildID := uuid.New() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + _, err := client.WorkspaceBuildTimings(ctx, buildID) + + // Then: expect a not found error + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + }) + + t.Run("EmptyTimings", func(t *testing.T) { + t.Parallel() + + // When: fetching timings for a build with no timings + build := makeBuild() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + res, err := client.WorkspaceBuildTimings(ctx, build.ID) + + // Then: return a response with empty timings + require.NoError(t, err) + require.Empty(t, res.ProvisionerTimings) + require.Empty(t, res.AgentScriptTimings) + }) + + t.Run("ProvisionerTimings", func(t *testing.T) { + t.Parallel() + + // When: fetching timings for a build with provisioner timings + build := makeBuild() + provisionerTimings := dbgen.ProvisionerJobTimings(t, db, build, 5) + + // Then: return a response with the expected timings + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + res, err := client.WorkspaceBuildTimings(ctx, build.ID) + require.NoError(t, err) + require.Len(t, res.ProvisionerTimings, 5) + + for i := range res.ProvisionerTimings { + timingRes := res.ProvisionerTimings[i] + genTiming := provisionerTimings[i] + require.Equal(t, genTiming.Resource, timingRes.Resource) + require.Equal(t, genTiming.Action, timingRes.Action) + require.Equal(t, string(genTiming.Stage), timingRes.Stage) + require.Equal(t, genTiming.JobID.String(), timingRes.JobID.String()) + require.Equal(t, genTiming.Source, timingRes.Source) + require.Equal(t, genTiming.StartedAt.UnixMilli(), timingRes.StartedAt.UnixMilli()) + require.Equal(t, genTiming.EndedAt.UnixMilli(), timingRes.EndedAt.UnixMilli()) + } + }) + + t.Run("AgentScriptTimings", func(t *testing.T) { + t.Parallel() + + // When: fetching timings for a build with agent script timings + build := makeBuild() + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: build.JobID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + script := dbgen.WorkspaceAgentScript(t, db, database.WorkspaceAgentScript{ + WorkspaceAgentID: agent.ID, + }) + agentScriptTimings := dbgen.WorkspaceAgentScriptTimings(t, db, script, 5) + + // Then: return a response with the expected timings + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + res, err := client.WorkspaceBuildTimings(ctx, build.ID) + require.NoError(t, err) + require.Len(t, res.AgentScriptTimings, 5) + + for i := range res.AgentScriptTimings { + timingRes := res.AgentScriptTimings[i] + genTiming := agentScriptTimings[i] + require.Equal(t, genTiming.ExitCode, timingRes.ExitCode) + require.Equal(t, string(genTiming.Status), timingRes.Status) + require.Equal(t, string(genTiming.Stage), timingRes.Stage) + require.Equal(t, genTiming.StartedAt.UnixMilli(), timingRes.StartedAt.UnixMilli()) + require.Equal(t, genTiming.EndedAt.UnixMilli(), timingRes.EndedAt.UnixMilli()) + } + }) + + t.Run("NoAgentScripts", func(t *testing.T) { + t.Parallel() + + // When: fetching timings for a build with no agent scripts + build := makeBuild() + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: build.JobID, + }) + dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + + // Then: return a response with empty agent script timings + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + res, err := client.WorkspaceBuildTimings(ctx, build.ID) + require.NoError(t, err) + require.Empty(t, res.AgentScriptTimings) + }) + + // Some workspaces might not have agents. It is improbable, but possible. + t.Run("NoAgents", func(t *testing.T) { + t.Parallel() + + // When: fetching timings for a build with no agents + build := makeBuild() + dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: build.JobID, + }) + + // Then: return a response with empty agent script timings + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + res, err := client.WorkspaceBuildTimings(ctx, build.ID) + require.NoError(t, err) + require.Empty(t, res.AgentScriptTimings) + }) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 30018a8c6b4d0..2407130ea38e4 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1746,7 +1746,7 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) -// @Success 200 {object} codersdk.WorkspaceTimings +// @Success 200 {object} codersdk.WorkspaceBuildTimings // @Router /workspaces/{workspace}/timings [get] func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { var ( @@ -1763,30 +1763,16 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { return } - provisionerTimings, err := api.Database.GetProvisionerJobTimingsByJobID(ctx, build.JobID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { + timings, err := api.buildTimings(ctx, build) + if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace timings.", + Message: "Internal error fetching timings.", Detail: err.Error(), }) return } - res := codersdk.WorkspaceTimings{ - ProvisionerTimings: make([]codersdk.ProvisionerTiming, 0, len(provisionerTimings)), - } - for _, t := range provisionerTimings { - res.ProvisionerTimings = append(res.ProvisionerTimings, codersdk.ProvisionerTiming{ - JobID: t.JobID, - Stage: string(t.Stage), - Source: t.Source, - Action: t.Action, - Resource: t.Resource, - StartedAt: t.StartedAt, - EndedAt: t.EndedAt, - }) - } - httpapi.Write(ctx, rw, http.StatusOK, res) + httpapi.Write(ctx, rw, http.StatusOK, timings) } type workspaceData struct { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 4f5064de48cbe..dc83289340059 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3560,165 +3560,109 @@ func TestWorkspaceNotifications(t *testing.T) { func TestWorkspaceTimings(t *testing.T) { t.Parallel() - // Setup a base template for the workspaces db, pubsub := dbtestutil.NewDB(t) client := coderdtest.New(t, &coderdtest.Options{ Database: db, Pubsub: pubsub, }) - owner := coderdtest.CreateFirstUser(t, client) - file := dbgen.File(t, db, database.File{ - CreatedBy: owner.UserID, - }) - versionJob := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ - OrganizationID: owner.OrganizationID, - InitiatorID: owner.UserID, - WorkerID: uuid.NullUUID{}, - FileID: file.ID, - Tags: database.StringMap{ - "custom": "true", - }, - }) - version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ - OrganizationID: owner.OrganizationID, - JobID: versionJob.ID, - CreatedBy: owner.UserID, - }) - template := dbgen.Template(t, db, database.Template{ - OrganizationID: owner.OrganizationID, - ActiveVersionID: version.ID, - CreatedBy: owner.UserID, - }) + coderdtest.CreateFirstUser(t, client) - // Since the tests run in parallel, we need to create a new workspace for - // each test to avoid fetching the wrong latest build. - type workspaceWithBuild struct { - database.Workspace - build database.WorkspaceBuild - } - makeWorkspace := func() workspaceWithBuild { - ws := dbgen.Workspace(t, db, database.Workspace{ - OwnerID: owner.UserID, + t.Run("LatestBuild", func(t *testing.T) { + t.Parallel() + + // Given: a workspace with many builds, provisioner, and agent script timings + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }) + owner := coderdtest.CreateFirstUser(t, client) + file := dbgen.File(t, db, database.File{ + CreatedBy: owner.UserID, + }) + versionJob := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ OrganizationID: owner.OrganizationID, - TemplateID: template.ID, - // Generate unique name for the workspace - Name: "test-workspace-" + uuid.New().String(), + InitiatorID: owner.UserID, + FileID: file.ID, + Tags: database.StringMap{ + "custom": "true", + }, }) - jobID := uuid.New() - job := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ - ID: jobID, + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ OrganizationID: owner.OrganizationID, - Type: database.ProvisionerJobTypeWorkspaceBuild, - Tags: database.StringMap{jobID.String(): "true"}, + JobID: versionJob.ID, + CreatedBy: owner.UserID, }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: ws.ID, - TemplateVersionID: version.ID, - BuildNumber: 1, - Transition: database.WorkspaceTransitionStart, - InitiatorID: owner.UserID, - JobID: job.ID, + template := dbgen.Template(t, db, database.Template{ + OrganizationID: owner.OrganizationID, + ActiveVersionID: version.ID, + CreatedBy: owner.UserID, + }) + ws := dbgen.Workspace(t, db, database.Workspace{ + OwnerID: owner.UserID, + OrganizationID: owner.OrganizationID, + TemplateID: template.ID, }) - return workspaceWithBuild{ - Workspace: ws, - build: build, - } - } - makeProvisionerTimings := func(jobID uuid.UUID, count int) []database.ProvisionerJobTiming { - // Use the database.ProvisionerJobTiming struct to mock timings data instead - // of directly creating database.InsertProvisionerJobTimingsParams. This - // approach makes the mock data easier to understand, as - // database.InsertProvisionerJobTimingsParams requires slices of each field - // for batch inserts. - timings := make([]database.ProvisionerJobTiming, count) - now := time.Now() - for i := range count { - startedAt := now.Add(-time.Hour + time.Duration(i)*time.Minute) - endedAt := startedAt.Add(time.Minute) - timings[i] = database.ProvisionerJobTiming{ - StartedAt: startedAt, - EndedAt: endedAt, - Stage: database.ProvisionerJobTimingStageInit, - Action: string(database.AuditActionCreate), - Source: "source", - Resource: fmt.Sprintf("resource[%d]", i), - } - } - insertParams := database.InsertProvisionerJobTimingsParams{ - JobID: jobID, - } - for _, timing := range timings { - insertParams.StartedAt = append(insertParams.StartedAt, timing.StartedAt) - insertParams.EndedAt = append(insertParams.EndedAt, timing.EndedAt) - insertParams.Stage = append(insertParams.Stage, timing.Stage) - insertParams.Action = append(insertParams.Action, timing.Action) - insertParams.Source = append(insertParams.Source, timing.Source) - insertParams.Resource = append(insertParams.Resource, timing.Resource) + // Create multiple builds + var buildNumber int32 + makeBuild := func() database.WorkspaceBuild { + buildNumber++ + jobID := uuid.New() + job := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + ID: jobID, + OrganizationID: owner.OrganizationID, + Tags: database.StringMap{jobID.String(): "true"}, + }) + return dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + TemplateVersionID: version.ID, + InitiatorID: owner.UserID, + JobID: job.ID, + BuildNumber: buildNumber, + }) } - return dbgen.ProvisionerJobTimings(t, db, insertParams) - } + makeBuild() + makeBuild() + latestBuild := makeBuild() - // Given - testCases := []struct { - name string - provisionerTimings int - workspace workspaceWithBuild - error bool - }{ - { - name: "workspace with 5 provisioner timings", - provisionerTimings: 5, - workspace: makeWorkspace(), - }, - { - name: "workspace with 2 provisioner timings", - provisionerTimings: 2, - workspace: makeWorkspace(), - }, - { - name: "workspace with 0 provisioner timings", - provisionerTimings: 0, - workspace: makeWorkspace(), - }, - { - name: "workspace not found", - provisionerTimings: 0, - workspace: workspaceWithBuild{}, - error: true, - }, - } + // Add provisioner timings + dbgen.ProvisionerJobTimings(t, db, latestBuild, 5) - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() + // Add agent script timings + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: latestBuild.JobID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + script := dbgen.WorkspaceAgentScript(t, db, database.WorkspaceAgentScript{ + WorkspaceAgentID: agent.ID, + }) + dbgen.WorkspaceAgentScriptTimings(t, db, script, 3) - // Generate timings based on test config - generatedTimings := makeProvisionerTimings(tc.workspace.build.JobID, tc.provisionerTimings) - res, err := client.WorkspaceTimings(context.Background(), tc.workspace.ID) + // When: fetching the timings + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + res, err := client.WorkspaceTimings(ctx, ws.ID) - // When error is expected, than an error is returned - if tc.error { - require.Error(t, err) - return - } + // Then: expect the timings to be returned + require.NoError(t, err) + require.Len(t, res.ProvisionerTimings, 5) + require.Len(t, res.AgentScriptTimings, 3) + }) - // When success is expected, than no error is returned and the length and - // fields are correctly returned - require.NoError(t, err) - require.Len(t, res.ProvisionerTimings, tc.provisionerTimings) - for i := range res.ProvisionerTimings { - timingRes := res.ProvisionerTimings[i] - genTiming := generatedTimings[i] - require.Equal(t, genTiming.Resource, timingRes.Resource) - require.Equal(t, genTiming.Action, timingRes.Action) - require.Equal(t, string(genTiming.Stage), timingRes.Stage) - require.Equal(t, genTiming.JobID.String(), timingRes.JobID.String()) - require.Equal(t, genTiming.Source, timingRes.Source) - require.Equal(t, genTiming.StartedAt.UnixMilli(), timingRes.StartedAt.UnixMilli()) - require.Equal(t, genTiming.EndedAt.UnixMilli(), timingRes.EndedAt.UnixMilli()) - } - }) - } + t.Run("NonExistentWorkspace", func(t *testing.T) { + t.Parallel() + + // When: fetching an inexistent workspace + workspaceID := uuid.New() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + _, err := client.WorkspaceTimings(ctx, workspaceID) + + // Then: expect a not found error + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + }) } diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 682cb424af1b1..3cb00c313f4bf 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -174,3 +174,41 @@ func (c *Client) WorkspaceBuildParameters(ctx context.Context, build uuid.UUID) var params []WorkspaceBuildParameter return params, json.NewDecoder(res.Body).Decode(¶ms) } + +type ProvisionerTiming struct { + JobID uuid.UUID `json:"job_id" format:"uuid"` + StartedAt time.Time `json:"started_at" format:"date-time"` + EndedAt time.Time `json:"ended_at" format:"date-time"` + Stage string `json:"stage"` + Source string `json:"source"` + Action string `json:"action"` + Resource string `json:"resource"` +} + +type AgentScriptTiming struct { + StartedAt time.Time `json:"started_at" format:"date-time"` + EndedAt time.Time `json:"ended_at" format:"date-time"` + ExitCode int32 `json:"exit_code"` + Stage string `json:"stage"` + Status string `json:"status"` + DisplayName string `json:"display_name"` +} + +type WorkspaceBuildTimings struct { + ProvisionerTimings []ProvisionerTiming `json:"provisioner_timings"` + AgentScriptTimings []AgentScriptTiming `json:"agent_script_timings"` +} + +func (c *Client) WorkspaceBuildTimings(ctx context.Context, build uuid.UUID) (WorkspaceBuildTimings, error) { + path := fmt.Sprintf("/api/v2/workspacebuilds/%s/timings", build.String()) + res, err := c.Request(ctx, http.MethodGet, path, nil) + if err != nil { + return WorkspaceBuildTimings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceBuildTimings{}, ReadBodyAsError(res) + } + var timings WorkspaceBuildTimings + return timings, json.NewDecoder(res.Body).Decode(&timings) +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 658af09cdda61..5ce1769150e02 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -626,32 +626,17 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) return nil } -type ProvisionerTiming struct { - JobID uuid.UUID `json:"job_id" format:"uuid"` - StartedAt time.Time `json:"started_at" format:"date-time"` - EndedAt time.Time `json:"ended_at" format:"date-time"` - Stage string `json:"stage"` - Source string `json:"source"` - Action string `json:"action"` - Resource string `json:"resource"` -} - -type WorkspaceTimings struct { - ProvisionerTimings []ProvisionerTiming `json:"provisioner_timings"` - // TODO: Add AgentScriptTimings when it is done https://github.com/coder/coder/issues/14630 -} - -func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceTimings, error) { +func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceBuildTimings, error) { path := fmt.Sprintf("/api/v2/workspaces/%s/timings", id.String()) res, err := c.Request(ctx, http.MethodGet, path, nil) if err != nil { - return WorkspaceTimings{}, err + return WorkspaceBuildTimings{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return WorkspaceTimings{}, ReadBodyAsError(res) + return WorkspaceBuildTimings{}, ReadBodyAsError(res) } - var timings WorkspaceTimings + var timings WorkspaceBuildTimings return timings, json.NewDecoder(res.Body).Decode(&timings) } diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index c0f1658e8ec8a..d49ab50fbb1ef 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -991,6 +991,63 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get workspace build timings by ID + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/timings \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspacebuilds/{workspacebuild}/timings` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------------- | ---- | ------------ | -------- | ------------------ | +| `workspacebuild` | path | string(uuid) | true | Workspace build ID | + +### Example responses + +> 200 Response + +```json +{ + "agent_script_timings": [ + { + "display_name": "string", + "ended_at": "2019-08-24T14:15:22Z", + "exit_code": 0, + "stage": "string", + "started_at": "2019-08-24T14:15:22Z", + "status": "string" + } + ], + "provisioner_timings": [ + { + "action": "string", + "ended_at": "2019-08-24T14:15:22Z", + "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", + "resource": "string", + "source": "string", + "stage": "string", + "started_at": "2019-08-24T14:15:22Z" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceBuildTimings](schemas.md#codersdkworkspacebuildtimings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace builds by workspace ID ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index bb756d1a7ea8f..7fdbb6562c000 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -349,6 +349,30 @@ | --------- | ------ | -------- | ------------ | ----------- | | `license` | string | true | | | +## codersdk.AgentScriptTiming + +```json +{ + "display_name": "string", + "ended_at": "2019-08-24T14:15:22Z", + "exit_code": 0, + "stage": "string", + "started_at": "2019-08-24T14:15:22Z", + "status": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `ended_at` | string | false | | | +| `exit_code` | integer | false | | | +| `stage` | string | false | | | +| `started_at` | string | false | | | +| `status` | string | false | | | + ## codersdk.AgentSubsystem ```json @@ -7316,6 +7340,41 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `name` | string | false | | | | `value` | string | false | | | +## codersdk.WorkspaceBuildTimings + +```json +{ + "agent_script_timings": [ + { + "display_name": "string", + "ended_at": "2019-08-24T14:15:22Z", + "exit_code": 0, + "stage": "string", + "started_at": "2019-08-24T14:15:22Z", + "status": "string" + } + ], + "provisioner_timings": [ + { + "action": "string", + "ended_at": "2019-08-24T14:15:22Z", + "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", + "resource": "string", + "source": "string", + "stage": "string", + "started_at": "2019-08-24T14:15:22Z" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | +| `agent_script_timings` | array of [codersdk.AgentScriptTiming](#codersdkagentscripttiming) | false | | | +| `provisioner_timings` | array of [codersdk.ProvisionerTiming](#codersdkprovisionertiming) | false | | | + ## codersdk.WorkspaceConnectionLatencyMS ```json @@ -7643,30 +7702,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `deleting` | | `deleted` | -## codersdk.WorkspaceTimings - -```json -{ - "provisioner_timings": [ - { - "action": "string", - "ended_at": "2019-08-24T14:15:22Z", - "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", - "resource": "string", - "source": "string", - "stage": "string", - "started_at": "2019-08-24T14:15:22Z" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -| --------------------- | ----------------------------------------------------------------- | -------- | ------------ | ----------- | -| `provisioner_timings` | array of [codersdk.ProvisionerTiming](#codersdkprovisionertiming) | false | | | - ## codersdk.WorkspaceTransition ```json diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 2987cf65159e4..283dab5db91b5 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1641,6 +1641,16 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/timings \ ```json { + "agent_script_timings": [ + { + "display_name": "string", + "ended_at": "2019-08-24T14:15:22Z", + "exit_code": 0, + "stage": "string", + "started_at": "2019-08-24T14:15:22Z", + "status": "string" + } + ], "provisioner_timings": [ { "action": "string", @@ -1657,9 +1667,9 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/timings \ ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceTimings](schemas.md#codersdkworkspacetimings) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceBuildTimings](schemas.md#codersdkworkspacebuildtimings) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bd40045c8c45e..3927160cee3e1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -32,6 +32,16 @@ export interface AddLicenseRequest { readonly license: string; } +// From codersdk/workspacebuilds.go +export interface AgentScriptTiming { + readonly started_at: string; + readonly ended_at: string; + readonly exit_code: number; + readonly stage: string; + readonly status: string; + readonly display_name: string; +} + // From codersdk/templates.go export interface AgentStatsReportResponse { readonly num_comms: number; @@ -1081,7 +1091,7 @@ export interface ProvisionerKeyDaemons { // From codersdk/provisionerdaemons.go export type ProvisionerKeyTags = Record -// From codersdk/workspaces.go +// From codersdk/workspacebuilds.go export interface ProvisionerTiming { readonly job_id: string; readonly started_at: string; @@ -1959,6 +1969,12 @@ export interface WorkspaceBuildParameter { readonly value: string; } +// From codersdk/workspacebuilds.go +export interface WorkspaceBuildTimings { + readonly provisioner_timings: Readonly>; + readonly agent_script_timings: Readonly>; +} + // From codersdk/workspaces.go export interface WorkspaceBuildsRequest extends Pagination { readonly since?: string; @@ -2050,11 +2066,6 @@ export interface WorkspaceResourceMetadata { readonly sensitive: boolean; } -// From codersdk/workspaces.go -export interface WorkspaceTimings { - readonly provisioner_timings: Readonly>; -} - // From codersdk/workspaces.go export interface WorkspacesRequest extends Pagination { readonly q?: string;