diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bb32fe53065d9..3815f713c0f4e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -18,6 +18,7 @@ import ( "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/rolestore" @@ -361,6 +362,27 @@ var ( }), Scope: rbac.ScopeAll, }.WithCachedASTValue() + + subjectPrebuildsOrchestrator = rbac.Subject{ + FriendlyName: "Prebuilds Orchestrator", + ID: prebuilds.SystemUserID.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "prebuilds-orchestrator"}, + DisplayName: "Coder", + Site: rbac.Permissions(map[string][]policy.Action{ + // May use template, read template-related info, & insert template-related resources (preset prebuilds). + rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionUse, policy.ActionViewInsights}, + // May CRUD workspaces, and start/stop them. + rbac.ResourceWorkspace.Type: { + policy.ActionCreate, policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, + policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, + }, + }), + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() ) // AsProvisionerd returns a context with an actor that has permissions required @@ -415,6 +437,12 @@ func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context { return context.WithValue(ctx, authContextKey{}, subjectSystemReadProvisionerDaemons) } +// AsPrebuildsOrchestrator returns a context with an actor that has permissions +// to read orchestrator workspace prebuilds. +func AsPrebuildsOrchestrator(ctx context.Context) context.Context { + return context.WithValue(ctx, authContextKey{}, subjectPrebuildsOrchestrator) +} + var AsRemoveActor = rbac.Subject{ ID: "remove-actor", } @@ -1109,6 +1137,31 @@ func (q *querier) BulkMarkNotificationMessagesSent(ctx context.Context, arg data return q.db.BulkMarkNotificationMessagesSent(ctx, arg) } +func (q *querier) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) { + empty := database.ClaimPrebuiltWorkspaceRow{} + + preset, err := q.db.GetPresetByID(ctx, arg.PresetID) + if err != nil { + return empty, err + } + + workspaceObject := rbac.ResourceWorkspace.WithOwner(arg.NewUserID.String()).InOrg(preset.OrganizationID) + err = q.authorizeContext(ctx, policy.ActionCreate, workspaceObject.RBACObject()) + if err != nil { + return empty, err + } + + tpl, err := q.GetTemplateByID(ctx, preset.TemplateID.UUID) + if err != nil { + return empty, xerrors.Errorf("verify template by id: %w", err) + } + if err := q.authorizeContext(ctx, policy.ActionUse, tpl); err != nil { + return empty, xerrors.Errorf("use template for workspace: %w", err) + } + + return q.db.ClaimPrebuiltWorkspace(ctx, arg) +} + func (q *querier) CleanTailnetCoordinators(ctx context.Context) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -1130,6 +1183,13 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } +func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil { + return nil, err + } + return q.db.CountInProgressPrebuilds(ctx) +} + func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceInboxNotification.WithOwner(userID.String())); err != nil { return 0, err @@ -2096,6 +2156,30 @@ func (q *querier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUI return q.db.GetParameterSchemasByJobID(ctx, jobID) } +func (q *querier) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + // GetPrebuildMetrics returns metrics related to prebuilt workspaces, + // such as the number of created and failed prebuilt workspaces. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil { + return nil, err + } + return q.db.GetPrebuildMetrics(ctx) +} + +func (q *querier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) { + empty := database.GetPresetByIDRow{} + + preset, err := q.db.GetPresetByID(ctx, presetID) + if err != nil { + return empty, err + } + _, err = q.GetTemplateByID(ctx, preset.TemplateID.UUID) + if err != nil { + return empty, err + } + + return preset, nil +} + func (q *querier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceID uuid.UUID) (database.TemplateVersionPreset, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate); err != nil { return database.TemplateVersionPreset{}, err @@ -2113,6 +2197,14 @@ func (q *querier) GetPresetParametersByTemplateVersionID(ctx context.Context, te return q.db.GetPresetParametersByTemplateVersionID(ctx, templateVersionID) } +func (q *querier) GetPresetsBackoff(ctx context.Context, lookback time.Time) ([]database.GetPresetsBackoffRow, error) { + // GetPresetsBackoff returns a list of template version presets along with metadata such as the number of failed prebuilds. + if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } + return q.db.GetPresetsBackoff(ctx, lookback) +} + func (q *querier) GetPresetsByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionPreset, error) { // An actor can read template version presets if they can read the related template version. _, err := q.GetTemplateVersionByID(ctx, templateVersionID) @@ -2164,13 +2256,13 @@ func (q *querier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (data // can read the job. _, err := q.GetWorkspaceBuildByJobID(ctx, id) if err != nil { - return database.ProvisionerJob{}, err + return database.ProvisionerJob{}, xerrors.Errorf("fetch related workspace build: %w", err) } case database.ProvisionerJobTypeTemplateVersionDryRun, database.ProvisionerJobTypeTemplateVersionImport: // Authorized call to get template version. _, err := authorizedTemplateVersionFromJob(ctx, q, job) if err != nil { - return database.ProvisionerJob{}, err + return database.ProvisionerJob{}, xerrors.Errorf("fetch related template version: %w", err) } default: return database.ProvisionerJob{}, xerrors.Errorf("unknown job type: %q", job.Type) @@ -2263,6 +2355,14 @@ func (q *querier) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Ti return q.db.GetReplicasUpdatedAfter(ctx, updatedAt) } +func (q *querier) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]database.GetRunningPrebuiltWorkspacesRow, error) { + // This query returns only prebuilt workspaces, but we decided to require permissions for all workspaces. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil { + return nil, err + } + return q.db.GetRunningPrebuiltWorkspaces(ctx) +} + func (q *querier) GetRuntimeConfig(ctx context.Context, key string) (string, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { return "", err @@ -2387,6 +2487,15 @@ func (q *querier) GetTemplateParameterInsights(ctx context.Context, arg database return q.db.GetTemplateParameterInsights(ctx, arg) } +func (q *querier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]database.GetTemplatePresetsWithPrebuildsRow, error) { + // GetTemplatePresetsWithPrebuilds retrieves template versions with configured presets and prebuilds. + // Presets and prebuilds are part of the template, so if you can access templates - you can access them as well. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } + return q.db.GetTemplatePresetsWithPrebuilds(ctx, templateID) +} + func (q *querier) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) { if err := q.authorizeTemplateInsights(ctx, arg.TemplateIDs); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 736df231b7401..0fe17f886b1b2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4838,6 +4838,96 @@ func (s *MethodTestSuite) TestNotifications() { })) } +func (s *MethodTestSuite) TestPrebuilds() { + s.Run("ClaimPrebuiltWorkspace", s.Subtest(func(db database.Store, check *expects) { + org := dbgen.Organization(s.T(), db, database.Organization{}) + user := dbgen.User(s.T(), db, database.User{}) + template := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{ + TemplateVersionID: templateVersion.ID, + }) + check.Args(database.ClaimPrebuiltWorkspaceParams{ + NewUserID: user.ID, + NewName: "", + PresetID: preset.ID, + }).Asserts( + rbac.ResourceWorkspace.WithOwner(user.ID.String()).InOrg(org.ID), policy.ActionCreate, + template, policy.ActionRead, + template, policy.ActionUse, + ).ErrorsWithInMemDB(dbmem.ErrUnimplemented). + ErrorsWithPG(sql.ErrNoRows) + })) + s.Run("GetPrebuildMetrics", s.Subtest(func(_ database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead). + ErrorsWithInMemDB(dbmem.ErrUnimplemented) + })) + s.Run("CountInProgressPrebuilds", s.Subtest(func(_ database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead). + ErrorsWithInMemDB(dbmem.ErrUnimplemented) + })) + s.Run("GetPresetsBackoff", s.Subtest(func(_ database.Store, check *expects) { + check.Args(time.Time{}). + Asserts(rbac.ResourceTemplate.All(), policy.ActionViewInsights). + ErrorsWithInMemDB(dbmem.ErrUnimplemented) + })) + s.Run("GetRunningPrebuiltWorkspaces", s.Subtest(func(_ database.Store, check *expects) { + check.Args(). + Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead). + ErrorsWithInMemDB(dbmem.ErrUnimplemented) + })) + s.Run("GetTemplatePresetsWithPrebuilds", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(uuid.NullUUID{UUID: user.ID, Valid: true}). + Asserts(rbac.ResourceTemplate.All(), policy.ActionRead). + ErrorsWithInMemDB(dbmem.ErrUnimplemented) + })) + s.Run("GetPresetByID", s.Subtest(func(db database.Store, check *expects) { + org := dbgen.Organization(s.T(), db, database.Organization{}) + user := dbgen.User(s.T(), db, database.User{}) + template := dbgen.Template(s.T(), db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + templateVersion := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + preset := dbgen.Preset(s.T(), db, database.InsertPresetParams{ + TemplateVersionID: templateVersion.ID, + }) + check.Args(preset.ID). + Asserts(template, policy.ActionRead). + Returns(database.GetPresetByIDRow{ + ID: preset.ID, + TemplateVersionID: preset.TemplateVersionID, + Name: preset.Name, + CreatedAt: preset.CreatedAt, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + OrganizationID: org.ID, + }) + })) +} + func (s *MethodTestSuite) TestOAuth2ProviderApps() { s.Run("GetOAuth2ProviderApps", s.Subtest(func(db database.Store, check *expects) { apps := []database.OAuth2ProviderApp{ diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1ea8d33757250..854c7c2974fe6 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1196,6 +1196,29 @@ func TelemetryItem(t testing.TB, db database.Store, seed database.TelemetryItem) return item } +func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) database.TemplateVersionPreset { + preset, err := db.InsertPreset(genCtx, database.InsertPresetParams{ + TemplateVersionID: takeFirst(seed.TemplateVersionID, uuid.New()), + Name: takeFirst(seed.Name, testutil.GetRandomName(t)), + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + DesiredInstances: seed.DesiredInstances, + InvalidateAfterSecs: seed.InvalidateAfterSecs, + }) + require.NoError(t, err, "insert preset") + return preset +} + +func PresetParameter(t testing.TB, db database.Store, seed database.InsertPresetParametersParams) []database.TemplateVersionPresetParameter { + parameters, err := db.InsertPresetParameters(genCtx, database.InsertPresetParametersParams{ + TemplateVersionPresetID: takeFirst(seed.TemplateVersionPresetID, uuid.New()), + Names: takeFirstSlice(seed.Names, []string{testutil.GetRandomName(t)}), + Values: takeFirstSlice(seed.Values, []string{testutil.GetRandomName(t)}), + }) + + require.NoError(t, err, "insert preset parameters") + return parameters +} + 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()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 6153e56de435e..bfae69fa68b98 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1741,6 +1741,10 @@ func (*FakeQuerier) BulkMarkNotificationMessagesSent(_ context.Context, arg data return int64(len(arg.IDs)), nil } +func (q *FakeQuerier) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) { + return database.ClaimPrebuiltWorkspaceRow{}, ErrUnimplemented +} + func (*FakeQuerier) CleanTailnetCoordinators(_ context.Context) error { return ErrUnimplemented } @@ -1753,6 +1757,10 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } +func (q *FakeQuerier) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) { + return nil, ErrUnimplemented +} + func (q *FakeQuerier) CountUnreadInboxNotificationsByUserID(_ context.Context, userID uuid.UUID) (int64, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4212,6 +4220,44 @@ func (q *FakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U return parameters, nil } +func (*FakeQuerier) GetPrebuildMetrics(_ context.Context) ([]database.GetPrebuildMetricsRow, error) { + return nil, ErrUnimplemented +} + +func (q *FakeQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + empty := database.GetPresetByIDRow{} + + // Create an index for faster lookup + versionMap := make(map[uuid.UUID]database.TemplateVersionTable) + for _, tv := range q.templateVersions { + versionMap[tv.ID] = tv + } + + for _, preset := range q.presets { + if preset.ID == presetID { + tv, ok := versionMap[preset.TemplateVersionID] + if !ok { + return empty, fmt.Errorf("template version %v does not exist", preset.TemplateVersionID) + } + return database.GetPresetByIDRow{ + ID: preset.ID, + TemplateVersionID: preset.TemplateVersionID, + Name: preset.Name, + CreatedAt: preset.CreatedAt, + DesiredInstances: preset.DesiredInstances, + InvalidateAfterSecs: preset.InvalidateAfterSecs, + TemplateID: tv.TemplateID, + OrganizationID: tv.OrganizationID, + }, nil + } + } + + return empty, fmt.Errorf("preset %v does not exist", presetID) +} + func (q *FakeQuerier) GetPresetByWorkspaceBuildID(_ context.Context, workspaceBuildID uuid.UUID) (database.TemplateVersionPreset, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4254,6 +4300,10 @@ func (q *FakeQuerier) GetPresetParametersByTemplateVersionID(_ context.Context, return parameters, nil } +func (*FakeQuerier) GetPresetsBackoff(_ context.Context, _ time.Time) ([]database.GetPresetsBackoffRow, error) { + return nil, ErrUnimplemented +} + func (q *FakeQuerier) GetPresetsByTemplateVersionID(_ context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionPreset, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4917,6 +4967,10 @@ func (q *FakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time. return replicas, nil } +func (q *FakeQuerier) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]database.GetRunningPrebuiltWorkspacesRow, error) { + return nil, ErrUnimplemented +} + func (q *FakeQuerier) GetRuntimeConfig(_ context.Context, key string) (string, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -5956,6 +6010,10 @@ func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg data return rows, nil } +func (*FakeQuerier) GetTemplatePresetsWithPrebuilds(_ context.Context, _ uuid.NullUUID) ([]database.GetTemplatePresetsWithPrebuildsRow, error) { + return nil, ErrUnimplemented +} + func (q *FakeQuerier) GetTemplateUsageStats(_ context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) { err := validateDatabaseType(arg) if err != nil { @@ -6426,6 +6484,10 @@ func (q *FakeQuerier) GetUserCount(_ context.Context, includeSystem bool) (int64 if !u.Deleted { existing++ } + + if !includeSystem && u.IsSystem { + continue + } } return existing, nil } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 6a945ce30d601..b29d95752d195 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -158,6 +158,13 @@ func (m queryMetricsStore) BulkMarkNotificationMessagesSent(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) { + start := time.Now() + r0, r1 := m.s.ClaimPrebuiltWorkspace(ctx, arg) + m.queryLatencies.WithLabelValues("ClaimPrebuiltWorkspace").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) CleanTailnetCoordinators(ctx context.Context) error { start := time.Now() err := m.s.CleanTailnetCoordinators(ctx) @@ -179,6 +186,13 @@ func (m queryMetricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } +func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) { + start := time.Now() + r0, r1 := m.s.CountInProgressPrebuilds(ctx) + m.queryLatencies.WithLabelValues("CountInProgressPrebuilds").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { start := time.Now() r0, r1 := m.s.CountUnreadInboxNotificationsByUserID(ctx, userID) @@ -1075,6 +1089,20 @@ func (m queryMetricsStore) GetParameterSchemasByJobID(ctx context.Context, jobID return schemas, err } +func (m queryMetricsStore) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + start := time.Now() + r0, r1 := m.s.GetPrebuildMetrics(ctx) + m.queryLatencies.WithLabelValues("GetPrebuildMetrics").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m queryMetricsStore) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetPresetByID(ctx, presetID) + m.queryLatencies.WithLabelValues("GetPresetByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceBuildID uuid.UUID) (database.TemplateVersionPreset, error) { start := time.Now() r0, r1 := m.s.GetPresetByWorkspaceBuildID(ctx, workspaceBuildID) @@ -1089,6 +1117,13 @@ func (m queryMetricsStore) GetPresetParametersByTemplateVersionID(ctx context.Co return r0, r1 } +func (m queryMetricsStore) GetPresetsBackoff(ctx context.Context, lookback time.Time) ([]database.GetPresetsBackoffRow, error) { + start := time.Now() + r0, r1 := m.s.GetPresetsBackoff(ctx, lookback) + m.queryLatencies.WithLabelValues("GetPresetsBackoff").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetPresetsByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionPreset, error) { start := time.Now() r0, r1 := m.s.GetPresetsByTemplateVersionID(ctx, templateVersionID) @@ -1222,6 +1257,13 @@ func (m queryMetricsStore) GetReplicasUpdatedAfter(ctx context.Context, updatedA return replicas, err } +func (m queryMetricsStore) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]database.GetRunningPrebuiltWorkspacesRow, error) { + start := time.Now() + r0, r1 := m.s.GetRunningPrebuiltWorkspaces(ctx) + m.queryLatencies.WithLabelValues("GetRunningPrebuiltWorkspaces").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetRuntimeConfig(ctx context.Context, key string) (string, error) { start := time.Now() r0, r1 := m.s.GetRuntimeConfig(ctx, key) @@ -1348,6 +1390,13 @@ func (m queryMetricsStore) GetTemplateParameterInsights(ctx context.Context, arg return r0, r1 } +func (m queryMetricsStore) GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]database.GetTemplatePresetsWithPrebuildsRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplatePresetsWithPrebuilds(ctx, templateID) + m.queryLatencies.WithLabelValues("GetTemplatePresetsWithPrebuilds").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) { start := time.Now() r0, r1 := m.s.GetTemplateUsageStats(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index aa4910c9b6925..e30759c6bba42 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -190,6 +190,21 @@ func (mr *MockStoreMockRecorder) BulkMarkNotificationMessagesSent(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkMarkNotificationMessagesSent", reflect.TypeOf((*MockStore)(nil).BulkMarkNotificationMessagesSent), ctx, arg) } +// ClaimPrebuiltWorkspace mocks base method. +func (m *MockStore) ClaimPrebuiltWorkspace(ctx context.Context, arg database.ClaimPrebuiltWorkspaceParams) (database.ClaimPrebuiltWorkspaceRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClaimPrebuiltWorkspace", ctx, arg) + ret0, _ := ret[0].(database.ClaimPrebuiltWorkspaceRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClaimPrebuiltWorkspace indicates an expected call of ClaimPrebuiltWorkspace. +func (mr *MockStoreMockRecorder) ClaimPrebuiltWorkspace(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClaimPrebuiltWorkspace", reflect.TypeOf((*MockStore)(nil).ClaimPrebuiltWorkspace), ctx, arg) +} + // CleanTailnetCoordinators mocks base method. func (m *MockStore) CleanTailnetCoordinators(ctx context.Context) error { m.ctrl.T.Helper() @@ -232,6 +247,21 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), ctx) } +// CountInProgressPrebuilds mocks base method. +func (m *MockStore) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountInProgressPrebuilds", ctx) + ret0, _ := ret[0].([]database.CountInProgressPrebuildsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountInProgressPrebuilds indicates an expected call of CountInProgressPrebuilds. +func (mr *MockStoreMockRecorder) CountInProgressPrebuilds(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountInProgressPrebuilds", reflect.TypeOf((*MockStore)(nil).CountInProgressPrebuilds), ctx) +} + // CountUnreadInboxNotificationsByUserID mocks base method. func (m *MockStore) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) { m.ctrl.T.Helper() @@ -2194,6 +2224,36 @@ func (mr *MockStoreMockRecorder) GetParameterSchemasByJobID(ctx, jobID any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParameterSchemasByJobID", reflect.TypeOf((*MockStore)(nil).GetParameterSchemasByJobID), ctx, jobID) } +// GetPrebuildMetrics mocks base method. +func (m *MockStore) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuildMetricsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPrebuildMetrics", ctx) + ret0, _ := ret[0].([]database.GetPrebuildMetricsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPrebuildMetrics indicates an expected call of GetPrebuildMetrics. +func (mr *MockStoreMockRecorder) GetPrebuildMetrics(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrebuildMetrics", reflect.TypeOf((*MockStore)(nil).GetPrebuildMetrics), ctx) +} + +// GetPresetByID mocks base method. +func (m *MockStore) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPresetByID", ctx, presetID) + ret0, _ := ret[0].(database.GetPresetByIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPresetByID indicates an expected call of GetPresetByID. +func (mr *MockStoreMockRecorder) GetPresetByID(ctx, presetID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPresetByID", reflect.TypeOf((*MockStore)(nil).GetPresetByID), ctx, presetID) +} + // GetPresetByWorkspaceBuildID mocks base method. func (m *MockStore) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceBuildID uuid.UUID) (database.TemplateVersionPreset, error) { m.ctrl.T.Helper() @@ -2224,6 +2284,21 @@ func (mr *MockStoreMockRecorder) GetPresetParametersByTemplateVersionID(ctx, tem return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPresetParametersByTemplateVersionID", reflect.TypeOf((*MockStore)(nil).GetPresetParametersByTemplateVersionID), ctx, templateVersionID) } +// GetPresetsBackoff mocks base method. +func (m *MockStore) GetPresetsBackoff(ctx context.Context, lookback time.Time) ([]database.GetPresetsBackoffRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPresetsBackoff", ctx, lookback) + ret0, _ := ret[0].([]database.GetPresetsBackoffRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPresetsBackoff indicates an expected call of GetPresetsBackoff. +func (mr *MockStoreMockRecorder) GetPresetsBackoff(ctx, lookback any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPresetsBackoff", reflect.TypeOf((*MockStore)(nil).GetPresetsBackoff), ctx, lookback) +} + // GetPresetsByTemplateVersionID mocks base method. func (m *MockStore) GetPresetsByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]database.TemplateVersionPreset, error) { m.ctrl.T.Helper() @@ -2509,6 +2584,21 @@ func (mr *MockStoreMockRecorder) GetReplicasUpdatedAfter(ctx, updatedAt any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReplicasUpdatedAfter", reflect.TypeOf((*MockStore)(nil).GetReplicasUpdatedAfter), ctx, updatedAt) } +// GetRunningPrebuiltWorkspaces mocks base method. +func (m *MockStore) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]database.GetRunningPrebuiltWorkspacesRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRunningPrebuiltWorkspaces", ctx) + ret0, _ := ret[0].([]database.GetRunningPrebuiltWorkspacesRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRunningPrebuiltWorkspaces indicates an expected call of GetRunningPrebuiltWorkspaces. +func (mr *MockStoreMockRecorder) GetRunningPrebuiltWorkspaces(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRunningPrebuiltWorkspaces", reflect.TypeOf((*MockStore)(nil).GetRunningPrebuiltWorkspaces), ctx) +} + // GetRuntimeConfig mocks base method. func (m *MockStore) GetRuntimeConfig(ctx context.Context, key string) (string, error) { m.ctrl.T.Helper() @@ -2794,6 +2884,21 @@ func (mr *MockStoreMockRecorder) GetTemplateParameterInsights(ctx, arg any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateParameterInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateParameterInsights), ctx, arg) } +// GetTemplatePresetsWithPrebuilds mocks base method. +func (m *MockStore) GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]database.GetTemplatePresetsWithPrebuildsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplatePresetsWithPrebuilds", ctx, templateID) + ret0, _ := ret[0].([]database.GetTemplatePresetsWithPrebuildsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplatePresetsWithPrebuilds indicates an expected call of GetTemplatePresetsWithPrebuilds. +func (mr *MockStoreMockRecorder) GetTemplatePresetsWithPrebuilds(ctx, templateID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplatePresetsWithPrebuilds", reflect.TypeOf((*MockStore)(nil).GetTemplatePresetsWithPrebuilds), ctx, templateID) +} + // GetTemplateUsageStats mocks base method. func (m *MockStore) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b4207c41deff2..8d9ac8186be85 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1401,7 +1401,9 @@ CREATE TABLE template_version_presets ( id uuid DEFAULT gen_random_uuid() NOT NULL, template_version_id uuid NOT NULL, name text NOT NULL, - created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + desired_instances integer, + invalidate_after_secs integer DEFAULT 0 ); CREATE TABLE template_version_terraform_values ( @@ -1991,6 +1993,19 @@ CREATE VIEW workspace_build_with_user AS COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; +CREATE VIEW workspace_latest_builds AS + SELECT DISTINCT ON (wb.workspace_id) wb.id, + wb.workspace_id, + wb.template_version_id, + wb.job_id, + wb.template_version_preset_id, + wb.transition, + wb.created_at, + pj.job_status + FROM (workspace_builds wb + JOIN provisioner_jobs pj ON ((wb.job_id = pj.id))) + ORDER BY wb.workspace_id, wb.build_number DESC; + CREATE TABLE workspace_modules ( id uuid NOT NULL, job_id uuid NOT NULL, @@ -2001,6 +2016,92 @@ CREATE TABLE workspace_modules ( created_at timestamp with time zone NOT NULL ); +CREATE VIEW workspace_prebuild_builds AS + SELECT workspace_builds.id, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.transition, + workspace_builds.job_id, + workspace_builds.template_version_preset_id, + workspace_builds.build_number + FROM workspace_builds + WHERE (workspace_builds.initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid); + +CREATE TABLE workspace_resources ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + job_id uuid NOT NULL, + transition workspace_transition NOT NULL, + type character varying(192) NOT NULL, + name character varying(64) NOT NULL, + hide boolean DEFAULT false NOT NULL, + icon character varying(256) DEFAULT ''::character varying NOT NULL, + instance_type character varying(256), + daily_cost integer DEFAULT 0 NOT NULL, + module_path text +); + +CREATE TABLE workspaces ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + owner_id uuid NOT NULL, + organization_id uuid NOT NULL, + template_id uuid NOT NULL, + deleted boolean DEFAULT false NOT NULL, + name character varying(64) NOT NULL, + autostart_schedule text, + ttl bigint, + last_used_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, + dormant_at timestamp with time zone, + deleting_at timestamp with time zone, + automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL, + favorite boolean DEFAULT false NOT NULL, + next_start_at timestamp with time zone +); + +COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.'; + +CREATE VIEW workspace_prebuilds AS + WITH all_prebuilds AS ( + SELECT w.id, + w.name, + w.template_id, + w.created_at + FROM workspaces w + WHERE (w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid) + ), workspaces_with_latest_presets AS ( + SELECT DISTINCT ON (workspace_builds.workspace_id) workspace_builds.workspace_id, + workspace_builds.template_version_preset_id + FROM workspace_builds + WHERE (workspace_builds.template_version_preset_id IS NOT NULL) + ORDER BY workspace_builds.workspace_id, workspace_builds.build_number DESC + ), workspaces_with_agents_status AS ( + SELECT w.id AS workspace_id, + bool_and((wa.lifecycle_state = 'ready'::workspace_agent_lifecycle_state)) AS ready + FROM (((workspaces w + JOIN workspace_latest_builds wlb ON ((wlb.workspace_id = w.id))) + JOIN workspace_resources wr ON ((wr.job_id = wlb.job_id))) + JOIN workspace_agents wa ON ((wa.resource_id = wr.id))) + WHERE (w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid) + GROUP BY w.id + ), current_presets AS ( + SELECT w.id AS prebuild_id, + wlp.template_version_preset_id + FROM (workspaces w + JOIN workspaces_with_latest_presets wlp ON ((wlp.workspace_id = w.id))) + WHERE (w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid) + ) + SELECT p.id, + p.name, + p.template_id, + p.created_at, + COALESCE(a.ready, false) AS ready, + cp.template_version_preset_id AS current_preset_id + FROM ((all_prebuilds p + LEFT JOIN workspaces_with_agents_status a ON ((a.workspace_id = p.id))) + JOIN current_presets cp ON ((cp.prebuild_id = p.id))); + CREATE TABLE workspace_proxies ( id uuid NOT NULL, name text NOT NULL, @@ -2057,41 +2158,6 @@ CREATE SEQUENCE workspace_resource_metadata_id_seq ALTER SEQUENCE workspace_resource_metadata_id_seq OWNED BY workspace_resource_metadata.id; -CREATE TABLE workspace_resources ( - id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - job_id uuid NOT NULL, - transition workspace_transition NOT NULL, - type character varying(192) NOT NULL, - name character varying(64) NOT NULL, - hide boolean DEFAULT false NOT NULL, - icon character varying(256) DEFAULT ''::character varying NOT NULL, - instance_type character varying(256), - daily_cost integer DEFAULT 0 NOT NULL, - module_path text -); - -CREATE TABLE workspaces ( - id uuid NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - owner_id uuid NOT NULL, - organization_id uuid NOT NULL, - template_id uuid NOT NULL, - deleted boolean DEFAULT false NOT NULL, - name character varying(64) NOT NULL, - autostart_schedule text, - ttl bigint, - last_used_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, - dormant_at timestamp with time zone, - deleting_at timestamp with time zone, - automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL, - favorite boolean DEFAULT false NOT NULL, - next_start_at timestamp with time zone -); - -COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.'; - CREATE VIEW workspaces_expanded AS SELECT workspaces.id, workspaces.created_at, @@ -2465,6 +2531,8 @@ CREATE INDEX idx_tailnet_tunnels_dst_id ON tailnet_tunnels USING hash (dst_id); CREATE INDEX idx_tailnet_tunnels_src_id ON tailnet_tunnels USING hash (src_id); +CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); + CREATE INDEX idx_user_deleted_deleted_at ON user_deleted USING btree (deleted_at); CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes USING btree (changed_at); diff --git a/coderd/database/lock.go b/coderd/database/lock.go index 025f7e71fca1a..7ccb3b8f56fec 100644 --- a/coderd/database/lock.go +++ b/coderd/database/lock.go @@ -12,6 +12,8 @@ const ( LockIDDBPurge LockIDNotificationsReportGenerator LockIDCryptoKeyRotation + LockIDReconcileTemplatePrebuilds + LockIDDeterminePrebuildsState ) // GenLockID generates a unique and consistent lock ID from a given string. diff --git a/coderd/database/migrations/000314_prebuilds.down.sql b/coderd/database/migrations/000314_prebuilds.down.sql new file mode 100644 index 0000000000000..bc8bc52e92da0 --- /dev/null +++ b/coderd/database/migrations/000314_prebuilds.down.sql @@ -0,0 +1,4 @@ +-- Revert prebuild views +DROP VIEW IF EXISTS workspace_prebuild_builds; +DROP VIEW IF EXISTS workspace_prebuilds; +DROP VIEW IF EXISTS workspace_latest_builds; diff --git a/coderd/database/migrations/000314_prebuilds.up.sql b/coderd/database/migrations/000314_prebuilds.up.sql new file mode 100644 index 0000000000000..0e8ff4ef6e408 --- /dev/null +++ b/coderd/database/migrations/000314_prebuilds.up.sql @@ -0,0 +1,62 @@ +-- workspace_latest_builds contains latest build for every workspace +CREATE VIEW workspace_latest_builds AS +SELECT DISTINCT ON (workspace_id) + wb.id, + wb.workspace_id, + wb.template_version_id, + wb.job_id, + wb.template_version_preset_id, + wb.transition, + wb.created_at, + pj.job_status +FROM workspace_builds wb + INNER JOIN provisioner_jobs pj ON wb.job_id = pj.id +ORDER BY wb.workspace_id, wb.build_number DESC; + +-- workspace_prebuilds contains all prebuilt workspaces with corresponding agent information +-- (including lifecycle_state which indicates is agent ready or not) and corresponding preset_id for prebuild +CREATE VIEW workspace_prebuilds AS +WITH + -- All workspaces owned by the "prebuilds" user. + all_prebuilds AS ( + SELECT w.id, w.name, w.template_id, w.created_at + FROM workspaces w + WHERE w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0' -- The system user responsible for prebuilds. + ), + -- We can't rely on the template_version_preset_id in the workspace_builds table because this value is only set on the + -- initial workspace creation. Subsequent stop/start transitions will not have a value for template_version_preset_id, + -- and therefore we can't rely on (say) the latest build's chosen template_version_preset_id. + -- + -- See https://github.com/coder/internal/issues/398 + workspaces_with_latest_presets AS ( + SELECT DISTINCT ON (workspace_id) workspace_id, template_version_preset_id + FROM workspace_builds + WHERE template_version_preset_id IS NOT NULL + ORDER BY workspace_id, build_number DESC + ), + -- workspaces_with_agents_status contains workspaces owned by the "prebuilds" user, + -- along with the readiness status of their agents. + -- A workspace is marked as 'ready' only if ALL of its agents are ready. + workspaces_with_agents_status AS ( + SELECT w.id AS workspace_id, + BOOL_AND(wa.lifecycle_state = 'ready'::workspace_agent_lifecycle_state) AS ready + FROM workspaces w + INNER JOIN workspace_latest_builds wlb ON wlb.workspace_id = w.id + INNER JOIN workspace_resources wr ON wr.job_id = wlb.job_id + INNER JOIN workspace_agents wa ON wa.resource_id = wr.id + WHERE w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0' -- The system user responsible for prebuilds. + GROUP BY w.id + ), + current_presets AS (SELECT w.id AS prebuild_id, wlp.template_version_preset_id + FROM workspaces w + INNER JOIN workspaces_with_latest_presets wlp ON wlp.workspace_id = w.id + WHERE w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0') -- The system user responsible for prebuilds. +SELECT p.id, p.name, p.template_id, p.created_at, COALESCE(a.ready, false) AS ready, cp.template_version_preset_id AS current_preset_id +FROM all_prebuilds p + LEFT JOIN workspaces_with_agents_status a ON a.workspace_id = p.id + INNER JOIN current_presets cp ON cp.prebuild_id = p.id; + +CREATE VIEW workspace_prebuild_builds AS +SELECT id, workspace_id, template_version_id, transition, job_id, template_version_preset_id, build_number +FROM workspace_builds +WHERE initiator_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'; -- The system user responsible for prebuilds. diff --git a/coderd/database/migrations/000315_preset_prebuilds.down.sql b/coderd/database/migrations/000315_preset_prebuilds.down.sql new file mode 100644 index 0000000000000..b5bd083e56037 --- /dev/null +++ b/coderd/database/migrations/000315_preset_prebuilds.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE template_version_presets + DROP COLUMN desired_instances, + DROP COLUMN invalidate_after_secs; + +DROP INDEX IF EXISTS idx_unique_preset_name; diff --git a/coderd/database/migrations/000315_preset_prebuilds.up.sql b/coderd/database/migrations/000315_preset_prebuilds.up.sql new file mode 100644 index 0000000000000..a4b31a5960539 --- /dev/null +++ b/coderd/database/migrations/000315_preset_prebuilds.up.sql @@ -0,0 +1,19 @@ +ALTER TABLE template_version_presets + ADD COLUMN desired_instances INT NULL, + ADD COLUMN invalidate_after_secs INT NULL DEFAULT 0; + +-- Ensure that the idx_unique_preset_name index creation won't fail. +-- This is necessary because presets were released before the index was introduced, +-- so existing data might violate the uniqueness constraint. +WITH ranked AS ( + SELECT id, name, template_version_id, + ROW_NUMBER() OVER (PARTITION BY name, template_version_id ORDER BY id) AS row_num + FROM template_version_presets +) +UPDATE template_version_presets +SET name = ranked.name || '_auto_' || row_num +FROM ranked +WHERE template_version_presets.id = ranked.id AND row_num > 1; + +-- We should not be able to have presets with the same name for a particular template version. +CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets (name, template_version_id); diff --git a/coderd/database/migrations/testdata/fixtures/000291_workspace_parameter_presets.up.sql b/coderd/database/migrations/testdata/fixtures/000291_workspace_parameter_presets.up.sql index 8eebf58e3f39c..296df73a587c3 100644 --- a/coderd/database/migrations/testdata/fixtures/000291_workspace_parameter_presets.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000291_workspace_parameter_presets.up.sql @@ -7,4 +7,26 @@ INSERT INTO public.template_versions (id, template_id, organization_id, created_ INSERT INTO public.template_version_presets (id, template_version_id, name, created_at) VALUES ('28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'af58bd62-428c-4c33-849b-d43a3be07d93', 'test', '0001-01-01 00:00:00.000000 +00:00'); +-- Add presets with the same template version ID and name +-- to ensure they're correctly handled by the 00031*_preset_prebuilds migration. +INSERT INTO public.template_version_presets ( + id, template_version_id, name, created_at +) +VALUES ( + 'c9dd1a63-f0cf-446e-8d6f-2d29d7c8e38b', + 'af58bd62-428c-4c33-849b-d43a3be07d93', + 'duplicate_name', + '0001-01-01 00:00:00.000000 +00:00' +); + +INSERT INTO public.template_version_presets ( + id, template_version_id, name, created_at +) +VALUES ( + '80f93d57-3948-487a-8990-bb011fb80a18', + 'af58bd62-428c-4c33-849b-d43a3be07d93', + 'duplicate_name', + '0001-01-01 00:00:00.000000 +00:00' +); + INSERT INTO public.template_version_preset_parameters (id, template_version_preset_id, name, value) VALUES ('ea90ccd2-5024-459e-87e4-879afd24de0f', '28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe', 'test', 'test'); diff --git a/coderd/database/migrations/testdata/fixtures/000315_preset_prebuilds.up.sql b/coderd/database/migrations/testdata/fixtures/000315_preset_prebuilds.up.sql new file mode 100644 index 0000000000000..c1f284b3e43c9 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000315_preset_prebuilds.up.sql @@ -0,0 +1,3 @@ +UPDATE template_version_presets +SET desired_instances = 1 +WHERE id = '28b42cc0-c4fe-4907-a0fe-e4d20f1e9bfe'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 4339191f7afa2..208b11cb26e71 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3170,10 +3170,12 @@ type TemplateVersionParameter struct { } type TemplateVersionPreset struct { - ID uuid.UUID `db:"id" json:"id"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - Name string `db:"name" json:"name"` - CreatedAt time.Time `db:"created_at" json:"created_at"` + ID uuid.UUID `db:"id" json:"id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + Name string `db:"name" json:"name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"` + InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` } type TemplateVersionPresetParameter struct { @@ -3636,6 +3638,17 @@ type WorkspaceBuildTable struct { TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` } +type WorkspaceLatestBuild struct { + ID uuid.UUID `db:"id" json:"id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + JobID uuid.UUID `db:"job_id" json:"job_id"` + TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` + Transition WorkspaceTransition `db:"transition" json:"transition"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + JobStatus ProvisionerJobStatus `db:"job_status" json:"job_status"` +} + type WorkspaceModule struct { ID uuid.UUID `db:"id" json:"id"` JobID uuid.UUID `db:"job_id" json:"job_id"` @@ -3646,6 +3659,25 @@ type WorkspaceModule struct { CreatedAt time.Time `db:"created_at" json:"created_at"` } +type WorkspacePrebuild struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Ready bool `db:"ready" json:"ready"` + CurrentPresetID uuid.NullUUID `db:"current_preset_id" json:"current_preset_id"` +} + +type WorkspacePrebuildBuild struct { + ID uuid.UUID `db:"id" json:"id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + Transition WorkspaceTransition `db:"transition" json:"transition"` + JobID uuid.UUID `db:"job_id" json:"job_id"` + TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` + BuildNumber int32 `db:"build_number" json:"build_number"` +} + type WorkspaceProxy struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3ecd2dc4217f4..54483c2176f4e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -60,9 +60,13 @@ type sqlcQuerier interface { BatchUpdateWorkspaceNextStartAt(ctx context.Context, arg BatchUpdateWorkspaceNextStartAtParams) error BulkMarkNotificationMessagesFailed(ctx context.Context, arg BulkMarkNotificationMessagesFailedParams) (int64, error) BulkMarkNotificationMessagesSent(ctx context.Context, arg BulkMarkNotificationMessagesSentParams) (int64, error) + ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebuiltWorkspaceParams) (ClaimPrebuiltWorkspaceRow, error) CleanTailnetCoordinators(ctx context.Context) error CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error + // CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by template version ID and transition. + // Prebuild considered in-progress if it's in the "starting", "stopping", or "deleting" state. + CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error) CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error @@ -230,8 +234,25 @@ type sqlcQuerier interface { GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error) GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) + GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error) + GetPresetByID(ctx context.Context, presetID uuid.UUID) (GetPresetByIDRow, error) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceBuildID uuid.UUID) (TemplateVersionPreset, error) GetPresetParametersByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionPresetParameter, error) + // GetPresetsBackoff groups workspace builds by preset ID. + // Each preset is associated with exactly one template version ID. + // For each group, the query checks up to N of the most recent jobs that occurred within the + // lookback period, where N equals the number of desired instances for the corresponding preset. + // If at least one of the job within a group has failed, we should backoff on the corresponding preset ID. + // Query returns a list of preset IDs for which we should backoff. + // Only active template versions with configured presets are considered. + // We also return the number of failed workspace builds that occurred during the lookback period. + // + // NOTE: + // - To **decide whether to back off**, we look at up to the N most recent builds (within the defined lookback period). + // - To **calculate the number of failed builds**, we consider all builds within the defined lookback period. + // + // The number of failed builds is used downstream to determine the backoff duration. + GetPresetsBackoff(ctx context.Context, lookback time.Time) ([]GetPresetsBackoffRow, error) GetPresetsByTemplateVersionID(ctx context.Context, templateVersionID uuid.UUID) ([]TemplateVersionPreset, error) GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) @@ -253,6 +274,7 @@ type sqlcQuerier interface { GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error) GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error) + GetRunningPrebuiltWorkspaces(ctx context.Context) ([]GetRunningPrebuiltWorkspacesRow, error) GetRuntimeConfig(ctx context.Context, key string) (string, error) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error) GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]TailnetClient, error) @@ -295,6 +317,10 @@ type sqlcQuerier interface { // created in the timeframe and return the aggregate usage counts of parameter // values. GetTemplateParameterInsights(ctx context.Context, arg GetTemplateParameterInsightsParams) ([]GetTemplateParameterInsightsRow, error) + // GetTemplatePresetsWithPrebuilds retrieves template versions with configured presets and prebuilds. + // It also returns the number of desired instances for each preset. + // If template_id is specified, only template versions associated with that template will be returned. + GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]GetTemplatePresetsWithPrebuildsRow, error) GetTemplateUsageStats(ctx context.Context, arg GetTemplateUsageStatsParams) ([]TemplateUsageStat, error) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 721a041929441..4a2edb4451c34 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -3587,6 +3588,782 @@ func TestOrganizationDeleteTrigger(t *testing.T) { }) } +type templateVersionWithPreset struct { + database.TemplateVersion + preset database.TemplateVersionPreset +} + +func createTemplate(t *testing.T, db database.Store, orgID uuid.UUID, userID uuid.UUID) database.Template { + // create template + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: orgID, + CreatedBy: userID, + ActiveVersionID: uuid.New(), + }) + + return tmpl +} + +type tmplVersionOpts struct { + DesiredInstances int32 +} + +func createTmplVersionAndPreset( + t *testing.T, + db database.Store, + tmpl database.Template, + versionID uuid.UUID, + now time.Time, + opts *tmplVersionOpts, +) templateVersionWithPreset { + // Create template version with corresponding preset and preset prebuild + tmplVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + ID: versionID, + TemplateID: uuid.NullUUID{ + UUID: tmpl.ID, + Valid: true, + }, + OrganizationID: tmpl.OrganizationID, + CreatedAt: now, + UpdatedAt: now, + CreatedBy: tmpl.CreatedBy, + }) + desiredInstances := int32(1) + if opts != nil { + desiredInstances = opts.DesiredInstances + } + preset := dbgen.Preset(t, db, database.InsertPresetParams{ + TemplateVersionID: tmplVersion.ID, + Name: "preset", + DesiredInstances: sql.NullInt32{ + Int32: desiredInstances, + Valid: true, + }, + }) + + return templateVersionWithPreset{ + TemplateVersion: tmplVersion, + preset: preset, + } +} + +type createPrebuiltWorkspaceOpts struct { + failedJob bool + createdAt time.Time + readyAgents int + notReadyAgents int +} + +func createPrebuiltWorkspace( + ctx context.Context, + t *testing.T, + db database.Store, + tmpl database.Template, + extTmplVersion templateVersionWithPreset, + orgID uuid.UUID, + now time.Time, + opts *createPrebuiltWorkspaceOpts, +) { + // Create job with corresponding resource and agent + jobError := sql.NullString{} + if opts != nil && opts.failedJob { + jobError = sql.NullString{String: "failed", Valid: true} + } + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + OrganizationID: orgID, + + CreatedAt: now.Add(-1 * time.Minute), + Error: jobError, + }) + + // create ready agents + readyAgents := 0 + if opts != nil { + readyAgents = opts.readyAgents + } + for i := 0; i < readyAgents; i++ { + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + err := db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }) + require.NoError(t, err) + } + + // create not ready agents + notReadyAgents := 1 + if opts != nil { + notReadyAgents = opts.notReadyAgents + } + for i := 0; i < notReadyAgents; i++ { + resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + err := db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateCreated, + }) + require.NoError(t, err) + } + + // Create corresponding workspace and workspace build + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: uuid.MustParse("c42fdf75-3097-471c-8c33-fb52454d81c0"), + OrganizationID: tmpl.OrganizationID, + TemplateID: tmpl.ID, + }) + createdAt := now + if opts != nil { + createdAt = opts.createdAt + } + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + CreatedAt: createdAt, + WorkspaceID: workspace.ID, + TemplateVersionID: extTmplVersion.ID, + BuildNumber: 1, + Transition: database.WorkspaceTransitionStart, + InitiatorID: tmpl.CreatedBy, + JobID: job.ID, + TemplateVersionPresetID: uuid.NullUUID{ + UUID: extTmplVersion.preset.ID, + Valid: true, + }, + }) +} + +func TestWorkspacePrebuildsView(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.SkipNow() + } + + now := dbtime.Now() + orgID := uuid.New() + userID := uuid.New() + + type workspacePrebuild struct { + ID uuid.UUID + Name string + CreatedAt time.Time + Ready bool + CurrentPresetID uuid.UUID + } + getWorkspacePrebuilds := func(sqlDB *sql.DB) []*workspacePrebuild { + rows, err := sqlDB.Query("SELECT id, name, created_at, ready, current_preset_id FROM workspace_prebuilds") + require.NoError(t, err) + defer rows.Close() + + workspacePrebuilds := make([]*workspacePrebuild, 0) + for rows.Next() { + var wp workspacePrebuild + err := rows.Scan(&wp.ID, &wp.Name, &wp.CreatedAt, &wp.Ready, &wp.CurrentPresetID) + require.NoError(t, err) + + workspacePrebuilds = append(workspacePrebuilds, &wp) + } + + return workspacePrebuilds + } + + testCases := []struct { + name string + readyAgents int + notReadyAgents int + expectReady bool + }{ + { + name: "one ready agent", + readyAgents: 1, + notReadyAgents: 0, + expectReady: true, + }, + { + name: "one not ready agent", + readyAgents: 0, + notReadyAgents: 1, + expectReady: false, + }, + { + name: "one ready, one not ready", + readyAgents: 1, + notReadyAgents: 1, + expectReady: false, + }, + { + name: "both ready", + readyAgents: 2, + notReadyAgents: 0, + expectReady: true, + }, + { + name: "five ready, one not ready", + readyAgents: 5, + notReadyAgents: 1, + expectReady: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + + ctx := testutil.Context(t, testutil.WaitShort) + + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl := createTemplate(t, db, orgID, userID) + tmplV1 := createTmplVersionAndPreset(t, db, tmpl, tmpl.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ + readyAgents: tc.readyAgents, + notReadyAgents: tc.notReadyAgents, + }) + + workspacePrebuilds := getWorkspacePrebuilds(sqlDB) + require.Len(t, workspacePrebuilds, 1) + require.Equal(t, tc.expectReady, workspacePrebuilds[0].Ready) + }) + } +} + +func TestGetPresetsBackoff(t *testing.T) { + t.Parallel() + if !dbtestutil.WillUsePostgres() { + t.SkipNow() + } + + now := dbtime.Now() + orgID := uuid.New() + userID := uuid.New() + + findBackoffByTmplVersionID := func(backoffs []database.GetPresetsBackoffRow, tmplVersionID uuid.UUID) *database.GetPresetsBackoffRow { + for _, backoff := range backoffs { + if backoff.TemplateVersionID == tmplVersionID { + return &backoff + } + } + + return nil + } + + t.Run("Single Workspace Build", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl := createTemplate(t, db, orgID, userID) + tmplV1 := createTmplVersionAndPreset(t, db, tmpl, tmpl.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + + require.Len(t, backoffs, 1) + backoff := backoffs[0] + require.Equal(t, backoff.TemplateVersionID, tmpl.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmplV1.preset.ID) + require.Equal(t, int32(1), backoff.NumFailed) + }) + + t.Run("Multiple Workspace Builds", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl := createTemplate(t, db, orgID, userID) + tmplV1 := createTmplVersionAndPreset(t, db, tmpl, tmpl.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + + require.Len(t, backoffs, 1) + backoff := backoffs[0] + require.Equal(t, backoff.TemplateVersionID, tmpl.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmplV1.preset.ID) + require.Equal(t, int32(3), backoff.NumFailed) + }) + + t.Run("Ignore Inactive Version", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl := createTemplate(t, db, orgID, userID) + tmplV1 := createTmplVersionAndPreset(t, db, tmpl, uuid.New(), now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + // Active Version + tmplV2 := createTmplVersionAndPreset(t, db, tmpl, tmpl.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV2, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl, tmplV2, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + + require.Len(t, backoffs, 1) + backoff := backoffs[0] + require.Equal(t, backoff.TemplateVersionID, tmpl.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmplV2.preset.ID) + require.Equal(t, int32(2), backoff.NumFailed) + }) + + t.Run("Multiple Templates", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + tmpl2 := createTemplate(t, db, orgID, userID) + tmpl2V1 := createTmplVersionAndPreset(t, db, tmpl2, tmpl2.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl2, tmpl2V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + + require.Len(t, backoffs, 2) + { + backoff := findBackoffByTmplVersionID(backoffs, tmpl1.ActiveVersionID) + require.Equal(t, backoff.TemplateVersionID, tmpl1.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl1V1.preset.ID) + require.Equal(t, int32(1), backoff.NumFailed) + } + { + backoff := findBackoffByTmplVersionID(backoffs, tmpl2.ActiveVersionID) + require.Equal(t, backoff.TemplateVersionID, tmpl2.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl2V1.preset.ID) + require.Equal(t, int32(1), backoff.NumFailed) + } + }) + + t.Run("Multiple Templates, Versions and Workspace Builds", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + tmpl2 := createTemplate(t, db, orgID, userID) + tmpl2V1 := createTmplVersionAndPreset(t, db, tmpl2, tmpl2.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl2, tmpl2V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl2, tmpl2V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + tmpl3 := createTemplate(t, db, orgID, userID) + tmpl3V1 := createTmplVersionAndPreset(t, db, tmpl3, uuid.New(), now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl3, tmpl3V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + tmpl3V2 := createTmplVersionAndPreset(t, db, tmpl3, tmpl3.ActiveVersionID, now, nil) + createPrebuiltWorkspace(ctx, t, db, tmpl3, tmpl3V2, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl3, tmpl3V2, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl3, tmpl3V2, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + + require.Len(t, backoffs, 3) + { + backoff := findBackoffByTmplVersionID(backoffs, tmpl1.ActiveVersionID) + require.Equal(t, backoff.TemplateVersionID, tmpl1.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl1V1.preset.ID) + require.Equal(t, int32(1), backoff.NumFailed) + } + { + backoff := findBackoffByTmplVersionID(backoffs, tmpl2.ActiveVersionID) + require.Equal(t, backoff.TemplateVersionID, tmpl2.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl2V1.preset.ID) + require.Equal(t, int32(2), backoff.NumFailed) + } + { + backoff := findBackoffByTmplVersionID(backoffs, tmpl3.ActiveVersionID) + require.Equal(t, backoff.TemplateVersionID, tmpl3.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl3V2.preset.ID) + require.Equal(t, int32(3), backoff.NumFailed) + } + }) + + t.Run("No Workspace Builds", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, nil) + _ = tmpl1V1 + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + require.Nil(t, backoffs) + }) + + t.Run("No Failed Workspace Builds", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, nil) + successfulJobOpts := createPrebuiltWorkspaceOpts{} + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &successfulJobOpts) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &successfulJobOpts) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &successfulJobOpts) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + require.Nil(t, backoffs) + }) + + t.Run("Last job is successful - no backoff", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, &tmplVersionOpts{ + DesiredInstances: 1, + }) + failedJobOpts := createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-2 * time.Minute), + } + successfulJobOpts := createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-1 * time.Minute), + } + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &failedJobOpts) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &successfulJobOpts) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + require.Nil(t, backoffs) + }) + + t.Run("Last 3 jobs are successful - no backoff", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, &tmplVersionOpts{ + DesiredInstances: 3, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-4 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-3 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-2 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-1 * time.Minute), + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + require.Nil(t, backoffs) + }) + + t.Run("1 job failed out of 3 - backoff", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, &tmplVersionOpts{ + DesiredInstances: 3, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-3 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-2 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-1 * time.Minute), + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-time.Hour)) + require.NoError(t, err) + + require.Len(t, backoffs, 1) + { + backoff := backoffs[0] + require.Equal(t, backoff.TemplateVersionID, tmpl1.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl1V1.preset.ID) + require.Equal(t, int32(1), backoff.NumFailed) + } + }) + + t.Run("3 job failed out of 5 - backoff", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + lookbackPeriod := time.Hour + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, &tmplVersionOpts{ + DesiredInstances: 3, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-lookbackPeriod - time.Minute), // earlier than lookback period - skipped + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-4 * time.Minute), // within lookback period - counted as failed job + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-3 * time.Minute), // within lookback period - counted as failed job + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-2 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: false, + createdAt: now.Add(-1 * time.Minute), + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-lookbackPeriod)) + require.NoError(t, err) + + require.Len(t, backoffs, 1) + { + backoff := backoffs[0] + require.Equal(t, backoff.TemplateVersionID, tmpl1.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl1V1.preset.ID) + require.Equal(t, int32(2), backoff.NumFailed) + } + }) + + t.Run("check LastBuildAt timestamp", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + lookbackPeriod := time.Hour + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, &tmplVersionOpts{ + DesiredInstances: 6, + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-lookbackPeriod - time.Minute), // earlier than lookback period - skipped + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-4 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-0 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-3 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-1 * time.Minute), + }) + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-2 * time.Minute), + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-lookbackPeriod)) + require.NoError(t, err) + + require.Len(t, backoffs, 1) + { + backoff := backoffs[0] + require.Equal(t, backoff.TemplateVersionID, tmpl1.ActiveVersionID) + require.Equal(t, backoff.PresetID, tmpl1V1.preset.ID) + require.Equal(t, int32(5), backoff.NumFailed) + // make sure LastBuildAt is equal to latest failed build timestamp + require.Equal(t, 0, now.Compare(backoff.LastBuildAt)) + } + }) + + t.Run("failed job outside lookback period", func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + dbgen.User(t, db, database.User{ + ID: userID, + }) + lookbackPeriod := time.Hour + + tmpl1 := createTemplate(t, db, orgID, userID) + tmpl1V1 := createTmplVersionAndPreset(t, db, tmpl1, tmpl1.ActiveVersionID, now, &tmplVersionOpts{ + DesiredInstances: 1, + }) + + createPrebuiltWorkspace(ctx, t, db, tmpl1, tmpl1V1, orgID, now, &createPrebuiltWorkspaceOpts{ + failedJob: true, + createdAt: now.Add(-lookbackPeriod - time.Minute), // earlier than lookback period - skipped + }) + + backoffs, err := db.GetPresetsBackoff(ctx, now.Add(-lookbackPeriod)) + require.NoError(t, err) + require.Len(t, backoffs, 0) + }) +} + func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) { t.Helper() require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ebc4a0da439c0..e1c7c3e65ab92 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5961,9 +5961,413 @@ func (q *sqlQuerier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid. return items, nil } +const claimPrebuiltWorkspace = `-- name: ClaimPrebuiltWorkspace :one +UPDATE workspaces w +SET owner_id = $1::uuid, + name = $2::text, + updated_at = NOW() +WHERE w.id IN ( + SELECT p.id + FROM workspace_prebuilds p + INNER JOIN workspace_latest_builds b ON b.workspace_id = p.id + INNER JOIN templates t ON p.template_id = t.id + WHERE (b.transition = 'start'::workspace_transition + AND b.job_status IN ('succeeded'::provisioner_job_status)) + -- The prebuilds system should never try to claim a prebuild for an inactive template version. + -- Nevertheless, this filter is here as a defensive measure: + AND b.template_version_id = t.active_version_id + AND p.current_preset_id = $3::uuid + AND p.ready + LIMIT 1 FOR UPDATE OF p SKIP LOCKED -- Ensure that a concurrent request will not select the same prebuild. +) +RETURNING w.id, w.name +` + +type ClaimPrebuiltWorkspaceParams struct { + NewUserID uuid.UUID `db:"new_user_id" json:"new_user_id"` + NewName string `db:"new_name" json:"new_name"` + PresetID uuid.UUID `db:"preset_id" json:"preset_id"` +} + +type ClaimPrebuiltWorkspaceRow struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebuiltWorkspaceParams) (ClaimPrebuiltWorkspaceRow, error) { + row := q.db.QueryRowContext(ctx, claimPrebuiltWorkspace, arg.NewUserID, arg.NewName, arg.PresetID) + var i ClaimPrebuiltWorkspaceRow + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const countInProgressPrebuilds = `-- name: CountInProgressPrebuilds :many +SELECT t.id AS template_id, wpb.template_version_id, wpb.transition, COUNT(wpb.transition)::int AS count +FROM workspace_latest_builds wlb + INNER JOIN workspace_prebuild_builds wpb ON wpb.id = wlb.id + -- We only need these counts for active template versions. + -- It doesn't influence whether we create or delete prebuilds + -- for inactive template versions. This is because we never create + -- prebuilds for inactive template versions, we always delete + -- running prebuilds for inactive template versions, and we ignore + -- prebuilds that are still building. + INNER JOIN templates t ON t.active_version_id = wlb.template_version_id +WHERE wlb.job_status IN ('pending'::provisioner_job_status, 'running'::provisioner_job_status) +GROUP BY t.id, wpb.template_version_id, wpb.transition +` + +type CountInProgressPrebuildsRow struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + Transition WorkspaceTransition `db:"transition" json:"transition"` + Count int32 `db:"count" json:"count"` +} + +// CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by template version ID and transition. +// Prebuild considered in-progress if it's in the "starting", "stopping", or "deleting" state. +func (q *sqlQuerier) CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error) { + rows, err := q.db.QueryContext(ctx, countInProgressPrebuilds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CountInProgressPrebuildsRow + for rows.Next() { + var i CountInProgressPrebuildsRow + if err := rows.Scan( + &i.TemplateID, + &i.TemplateVersionID, + &i.Transition, + &i.Count, + ); 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 getPrebuildMetrics = `-- name: GetPrebuildMetrics :many +SELECT + t.name as template_name, + tvp.name as preset_name, + o.name as organization_name, + COUNT(*) as created_count, + COUNT(*) FILTER (WHERE pj.job_status = 'failed'::provisioner_job_status) as failed_count, + COUNT(*) FILTER ( + WHERE w.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid -- The system user responsible for prebuilds. + ) as claimed_count +FROM workspaces w +INNER JOIN workspace_prebuild_builds wpb ON wpb.workspace_id = w.id +INNER JOIN templates t ON t.id = w.template_id +INNER JOIN template_version_presets tvp ON tvp.id = wpb.template_version_preset_id +INNER JOIN provisioner_jobs pj ON pj.id = wpb.job_id +INNER JOIN organizations o ON o.id = w.organization_id +WHERE NOT t.deleted AND wpb.build_number = 1 +GROUP BY t.name, tvp.name, o.name +ORDER BY t.name, tvp.name, o.name +` + +type GetPrebuildMetricsRow struct { + TemplateName string `db:"template_name" json:"template_name"` + PresetName string `db:"preset_name" json:"preset_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + CreatedCount int64 `db:"created_count" json:"created_count"` + FailedCount int64 `db:"failed_count" json:"failed_count"` + ClaimedCount int64 `db:"claimed_count" json:"claimed_count"` +} + +func (q *sqlQuerier) GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error) { + rows, err := q.db.QueryContext(ctx, getPrebuildMetrics) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPrebuildMetricsRow + for rows.Next() { + var i GetPrebuildMetricsRow + if err := rows.Scan( + &i.TemplateName, + &i.PresetName, + &i.OrganizationName, + &i.CreatedCount, + &i.FailedCount, + &i.ClaimedCount, + ); 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 getPresetsBackoff = `-- name: GetPresetsBackoff :many +WITH filtered_builds AS ( + -- Only select builds which are for prebuild creations + SELECT wlb.template_version_id, wlb.created_at, tvp.id AS preset_id, wlb.job_status, tvp.desired_instances + FROM template_version_presets tvp + INNER JOIN workspace_latest_builds wlb ON wlb.template_version_preset_id = tvp.id + INNER JOIN workspaces w ON wlb.workspace_id = w.id + INNER JOIN template_versions tv ON wlb.template_version_id = tv.id + INNER JOIN templates t ON tv.template_id = t.id AND t.active_version_id = tv.id + WHERE tvp.desired_instances IS NOT NULL -- Consider only presets that have a prebuild configuration. + AND wlb.transition = 'start'::workspace_transition + AND w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0' +), +time_sorted_builds AS ( + -- Group builds by preset, then sort each group by created_at. + SELECT fb.template_version_id, fb.created_at, fb.preset_id, fb.job_status, fb.desired_instances, + ROW_NUMBER() OVER (PARTITION BY fb.preset_id ORDER BY fb.created_at DESC) as rn + FROM filtered_builds fb +), +failed_count AS ( + -- Count failed builds per preset in the given period + SELECT preset_id, COUNT(*) AS num_failed + FROM filtered_builds + WHERE job_status = 'failed'::provisioner_job_status + AND created_at >= $1::timestamptz + GROUP BY preset_id +) +SELECT + tsb.template_version_id, + tsb.preset_id, + COALESCE(fc.num_failed, 0)::int AS num_failed, + MAX(tsb.created_at)::timestamptz AS last_build_at +FROM time_sorted_builds tsb + LEFT JOIN failed_count fc ON fc.preset_id = tsb.preset_id +WHERE tsb.rn <= tsb.desired_instances -- Fetch the last N builds, where N is the number of desired instances; if any fail, we backoff + AND tsb.job_status = 'failed'::provisioner_job_status + AND created_at >= $1::timestamptz +GROUP BY tsb.template_version_id, tsb.preset_id, fc.num_failed +` + +type GetPresetsBackoffRow struct { + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + PresetID uuid.UUID `db:"preset_id" json:"preset_id"` + NumFailed int32 `db:"num_failed" json:"num_failed"` + LastBuildAt time.Time `db:"last_build_at" json:"last_build_at"` +} + +// GetPresetsBackoff groups workspace builds by preset ID. +// Each preset is associated with exactly one template version ID. +// For each group, the query checks up to N of the most recent jobs that occurred within the +// lookback period, where N equals the number of desired instances for the corresponding preset. +// If at least one of the job within a group has failed, we should backoff on the corresponding preset ID. +// Query returns a list of preset IDs for which we should backoff. +// Only active template versions with configured presets are considered. +// We also return the number of failed workspace builds that occurred during the lookback period. +// +// NOTE: +// - To **decide whether to back off**, we look at up to the N most recent builds (within the defined lookback period). +// - To **calculate the number of failed builds**, we consider all builds within the defined lookback period. +// +// The number of failed builds is used downstream to determine the backoff duration. +func (q *sqlQuerier) GetPresetsBackoff(ctx context.Context, lookback time.Time) ([]GetPresetsBackoffRow, error) { + rows, err := q.db.QueryContext(ctx, getPresetsBackoff, lookback) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPresetsBackoffRow + for rows.Next() { + var i GetPresetsBackoffRow + if err := rows.Scan( + &i.TemplateVersionID, + &i.PresetID, + &i.NumFailed, + &i.LastBuildAt, + ); 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 getRunningPrebuiltWorkspaces = `-- name: GetRunningPrebuiltWorkspaces :many +SELECT + p.id, + p.name, + p.template_id, + b.template_version_id, + p.current_preset_id AS current_preset_id, + p.ready, + p.created_at +FROM workspace_prebuilds p + INNER JOIN workspace_latest_builds b ON b.workspace_id = p.id +WHERE (b.transition = 'start'::workspace_transition + AND b.job_status = 'succeeded'::provisioner_job_status) +` + +type GetRunningPrebuiltWorkspacesRow struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + CurrentPresetID uuid.NullUUID `db:"current_preset_id" json:"current_preset_id"` + Ready bool `db:"ready" json:"ready"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (q *sqlQuerier) GetRunningPrebuiltWorkspaces(ctx context.Context) ([]GetRunningPrebuiltWorkspacesRow, error) { + rows, err := q.db.QueryContext(ctx, getRunningPrebuiltWorkspaces) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRunningPrebuiltWorkspacesRow + for rows.Next() { + var i GetRunningPrebuiltWorkspacesRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TemplateID, + &i.TemplateVersionID, + &i.CurrentPresetID, + &i.Ready, + &i.CreatedAt, + ); 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 getTemplatePresetsWithPrebuilds = `-- name: GetTemplatePresetsWithPrebuilds :many +SELECT + t.id AS template_id, + t.name AS template_name, + o.name AS organization_name, + tv.id AS template_version_id, + tv.name AS template_version_name, + tv.id = t.active_version_id AS using_active_version, + tvp.id, + tvp.name, + tvp.desired_instances AS desired_instances, + t.deleted, + t.deprecated != '' AS deprecated +FROM templates t + INNER JOIN template_versions tv ON tv.template_id = t.id + INNER JOIN template_version_presets tvp ON tvp.template_version_id = tv.id + INNER JOIN organizations o ON o.id = t.organization_id +WHERE tvp.desired_instances IS NOT NULL -- Consider only presets that have a prebuild configuration. + AND (t.id = $1::uuid OR $1 IS NULL) +` + +type GetTemplatePresetsWithPrebuildsRow struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + TemplateName string `db:"template_name" json:"template_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName string `db:"template_version_name" json:"template_version_name"` + UsingActiveVersion bool `db:"using_active_version" json:"using_active_version"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"` + Deleted bool `db:"deleted" json:"deleted"` + Deprecated bool `db:"deprecated" json:"deprecated"` +} + +// GetTemplatePresetsWithPrebuilds retrieves template versions with configured presets and prebuilds. +// It also returns the number of desired instances for each preset. +// If template_id is specified, only template versions associated with that template will be returned. +func (q *sqlQuerier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]GetTemplatePresetsWithPrebuildsRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplatePresetsWithPrebuilds, templateID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplatePresetsWithPrebuildsRow + for rows.Next() { + var i GetTemplatePresetsWithPrebuildsRow + if err := rows.Scan( + &i.TemplateID, + &i.TemplateName, + &i.OrganizationName, + &i.TemplateVersionID, + &i.TemplateVersionName, + &i.UsingActiveVersion, + &i.ID, + &i.Name, + &i.DesiredInstances, + &i.Deleted, + &i.Deprecated, + ); 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 getPresetByID = `-- name: GetPresetByID :one +SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tv.template_id, tv.organization_id FROM + template_version_presets tvp + INNER JOIN template_versions tv ON tvp.template_version_id = tv.id +WHERE tvp.id = $1 +` + +type GetPresetByIDRow struct { + ID uuid.UUID `db:"id" json:"id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + Name string `db:"name" json:"name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"` + InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` + TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (GetPresetByIDRow, error) { + row := q.db.QueryRowContext(ctx, getPresetByID, presetID) + var i GetPresetByIDRow + err := row.Scan( + &i.ID, + &i.TemplateVersionID, + &i.Name, + &i.CreatedAt, + &i.DesiredInstances, + &i.InvalidateAfterSecs, + &i.TemplateID, + &i.OrganizationID, + ) + return i, err +} + const getPresetByWorkspaceBuildID = `-- name: GetPresetByWorkspaceBuildID :one SELECT - template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at + template_version_presets.id, template_version_presets.template_version_id, template_version_presets.name, template_version_presets.created_at, template_version_presets.desired_instances, template_version_presets.invalidate_after_secs FROM template_version_presets INNER JOIN workspace_builds ON workspace_builds.template_version_preset_id = template_version_presets.id @@ -5979,6 +6383,8 @@ func (q *sqlQuerier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceB &i.TemplateVersionID, &i.Name, &i.CreatedAt, + &i.DesiredInstances, + &i.InvalidateAfterSecs, ) return i, err } @@ -6023,7 +6429,7 @@ func (q *sqlQuerier) GetPresetParametersByTemplateVersionID(ctx context.Context, const getPresetsByTemplateVersionID = `-- name: GetPresetsByTemplateVersionID :many SELECT - id, template_version_id, name, created_at + id, template_version_id, name, created_at, desired_instances, invalidate_after_secs FROM template_version_presets WHERE @@ -6044,6 +6450,8 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template &i.TemplateVersionID, &i.Name, &i.CreatedAt, + &i.DesiredInstances, + &i.InvalidateAfterSecs, ); err != nil { return nil, err } @@ -6059,26 +6467,46 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template } const insertPreset = `-- name: InsertPreset :one -INSERT INTO - template_version_presets (template_version_id, name, created_at) -VALUES - ($1, $2, $3) RETURNING id, template_version_id, name, created_at +INSERT INTO template_version_presets ( + template_version_id, + name, + created_at, + desired_instances, + invalidate_after_secs +) +VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs ` type InsertPresetParams struct { - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - Name string `db:"name" json:"name"` - CreatedAt time.Time `db:"created_at" json:"created_at"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + Name string `db:"name" json:"name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + DesiredInstances sql.NullInt32 `db:"desired_instances" json:"desired_instances"` + InvalidateAfterSecs sql.NullInt32 `db:"invalidate_after_secs" json:"invalidate_after_secs"` } func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error) { - row := q.db.QueryRowContext(ctx, insertPreset, arg.TemplateVersionID, arg.Name, arg.CreatedAt) + row := q.db.QueryRowContext(ctx, insertPreset, + arg.TemplateVersionID, + arg.Name, + arg.CreatedAt, + arg.DesiredInstances, + arg.InvalidateAfterSecs, + ) var i TemplateVersionPreset err := row.Scan( &i.ID, &i.TemplateVersionID, &i.Name, &i.CreatedAt, + &i.DesiredInstances, + &i.InvalidateAfterSecs, ) return i, err } diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql new file mode 100644 index 0000000000000..53f5020f3607e --- /dev/null +++ b/coderd/database/queries/prebuilds.sql @@ -0,0 +1,146 @@ +-- name: ClaimPrebuiltWorkspace :one +UPDATE workspaces w +SET owner_id = @new_user_id::uuid, + name = @new_name::text, + updated_at = NOW() +WHERE w.id IN ( + SELECT p.id + FROM workspace_prebuilds p + INNER JOIN workspace_latest_builds b ON b.workspace_id = p.id + INNER JOIN templates t ON p.template_id = t.id + WHERE (b.transition = 'start'::workspace_transition + AND b.job_status IN ('succeeded'::provisioner_job_status)) + -- The prebuilds system should never try to claim a prebuild for an inactive template version. + -- Nevertheless, this filter is here as a defensive measure: + AND b.template_version_id = t.active_version_id + AND p.current_preset_id = @preset_id::uuid + AND p.ready + LIMIT 1 FOR UPDATE OF p SKIP LOCKED -- Ensure that a concurrent request will not select the same prebuild. +) +RETURNING w.id, w.name; + +-- name: GetTemplatePresetsWithPrebuilds :many +-- GetTemplatePresetsWithPrebuilds retrieves template versions with configured presets and prebuilds. +-- It also returns the number of desired instances for each preset. +-- If template_id is specified, only template versions associated with that template will be returned. +SELECT + t.id AS template_id, + t.name AS template_name, + o.name AS organization_name, + tv.id AS template_version_id, + tv.name AS template_version_name, + tv.id = t.active_version_id AS using_active_version, + tvp.id, + tvp.name, + tvp.desired_instances AS desired_instances, + t.deleted, + t.deprecated != '' AS deprecated +FROM templates t + INNER JOIN template_versions tv ON tv.template_id = t.id + INNER JOIN template_version_presets tvp ON tvp.template_version_id = tv.id + INNER JOIN organizations o ON o.id = t.organization_id +WHERE tvp.desired_instances IS NOT NULL -- Consider only presets that have a prebuild configuration. + AND (t.id = sqlc.narg('template_id')::uuid OR sqlc.narg('template_id') IS NULL); + +-- name: GetRunningPrebuiltWorkspaces :many +SELECT + p.id, + p.name, + p.template_id, + b.template_version_id, + p.current_preset_id AS current_preset_id, + p.ready, + p.created_at +FROM workspace_prebuilds p + INNER JOIN workspace_latest_builds b ON b.workspace_id = p.id +WHERE (b.transition = 'start'::workspace_transition + AND b.job_status = 'succeeded'::provisioner_job_status); + +-- name: CountInProgressPrebuilds :many +-- CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by template version ID and transition. +-- Prebuild considered in-progress if it's in the "starting", "stopping", or "deleting" state. +SELECT t.id AS template_id, wpb.template_version_id, wpb.transition, COUNT(wpb.transition)::int AS count +FROM workspace_latest_builds wlb + INNER JOIN workspace_prebuild_builds wpb ON wpb.id = wlb.id + -- We only need these counts for active template versions. + -- It doesn't influence whether we create or delete prebuilds + -- for inactive template versions. This is because we never create + -- prebuilds for inactive template versions, we always delete + -- running prebuilds for inactive template versions, and we ignore + -- prebuilds that are still building. + INNER JOIN templates t ON t.active_version_id = wlb.template_version_id +WHERE wlb.job_status IN ('pending'::provisioner_job_status, 'running'::provisioner_job_status) +GROUP BY t.id, wpb.template_version_id, wpb.transition; + +-- GetPresetsBackoff groups workspace builds by preset ID. +-- Each preset is associated with exactly one template version ID. +-- For each group, the query checks up to N of the most recent jobs that occurred within the +-- lookback period, where N equals the number of desired instances for the corresponding preset. +-- If at least one of the job within a group has failed, we should backoff on the corresponding preset ID. +-- Query returns a list of preset IDs for which we should backoff. +-- Only active template versions with configured presets are considered. +-- We also return the number of failed workspace builds that occurred during the lookback period. +-- +-- NOTE: +-- - To **decide whether to back off**, we look at up to the N most recent builds (within the defined lookback period). +-- - To **calculate the number of failed builds**, we consider all builds within the defined lookback period. +-- +-- The number of failed builds is used downstream to determine the backoff duration. +-- name: GetPresetsBackoff :many +WITH filtered_builds AS ( + -- Only select builds which are for prebuild creations + SELECT wlb.template_version_id, wlb.created_at, tvp.id AS preset_id, wlb.job_status, tvp.desired_instances + FROM template_version_presets tvp + INNER JOIN workspace_latest_builds wlb ON wlb.template_version_preset_id = tvp.id + INNER JOIN workspaces w ON wlb.workspace_id = w.id + INNER JOIN template_versions tv ON wlb.template_version_id = tv.id + INNER JOIN templates t ON tv.template_id = t.id AND t.active_version_id = tv.id + WHERE tvp.desired_instances IS NOT NULL -- Consider only presets that have a prebuild configuration. + AND wlb.transition = 'start'::workspace_transition + AND w.owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0' +), +time_sorted_builds AS ( + -- Group builds by preset, then sort each group by created_at. + SELECT fb.template_version_id, fb.created_at, fb.preset_id, fb.job_status, fb.desired_instances, + ROW_NUMBER() OVER (PARTITION BY fb.preset_id ORDER BY fb.created_at DESC) as rn + FROM filtered_builds fb +), +failed_count AS ( + -- Count failed builds per preset in the given period + SELECT preset_id, COUNT(*) AS num_failed + FROM filtered_builds + WHERE job_status = 'failed'::provisioner_job_status + AND created_at >= @lookback::timestamptz + GROUP BY preset_id +) +SELECT + tsb.template_version_id, + tsb.preset_id, + COALESCE(fc.num_failed, 0)::int AS num_failed, + MAX(tsb.created_at)::timestamptz AS last_build_at +FROM time_sorted_builds tsb + LEFT JOIN failed_count fc ON fc.preset_id = tsb.preset_id +WHERE tsb.rn <= tsb.desired_instances -- Fetch the last N builds, where N is the number of desired instances; if any fail, we backoff + AND tsb.job_status = 'failed'::provisioner_job_status + AND created_at >= @lookback::timestamptz +GROUP BY tsb.template_version_id, tsb.preset_id, fc.num_failed; + +-- name: GetPrebuildMetrics :many +SELECT + t.name as template_name, + tvp.name as preset_name, + o.name as organization_name, + COUNT(*) as created_count, + COUNT(*) FILTER (WHERE pj.job_status = 'failed'::provisioner_job_status) as failed_count, + COUNT(*) FILTER ( + WHERE w.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::uuid -- The system user responsible for prebuilds. + ) as claimed_count +FROM workspaces w +INNER JOIN workspace_prebuild_builds wpb ON wpb.workspace_id = w.id +INNER JOIN templates t ON t.id = w.template_id +INNER JOIN template_version_presets tvp ON tvp.id = wpb.template_version_preset_id +INNER JOIN provisioner_jobs pj ON pj.id = wpb.job_id +INNER JOIN organizations o ON o.id = w.organization_id +WHERE NOT t.deleted AND wpb.build_number = 1 +GROUP BY t.name, tvp.name, o.name +ORDER BY t.name, tvp.name, o.name; diff --git a/coderd/database/queries/presets.sql b/coderd/database/queries/presets.sql index 8e648fce6ca88..526d7d0a95c3c 100644 --- a/coderd/database/queries/presets.sql +++ b/coderd/database/queries/presets.sql @@ -1,8 +1,18 @@ -- name: InsertPreset :one -INSERT INTO - template_version_presets (template_version_id, name, created_at) -VALUES - (@template_version_id, @name, @created_at) RETURNING *; +INSERT INTO template_version_presets ( + template_version_id, + name, + created_at, + desired_instances, + invalidate_after_secs +) +VALUES ( + @template_version_id, + @name, + @created_at, + @desired_instances, + @invalidate_after_secs +) RETURNING *; -- name: InsertPresetParameters :many INSERT INTO @@ -38,3 +48,9 @@ FROM INNER JOIN template_version_presets ON template_version_preset_parameters.template_version_preset_id = template_version_presets.id WHERE template_version_presets.template_version_id = @template_version_id; + +-- name: GetPresetByID :one +SELECT tvp.*, tv.template_id, tv.organization_id FROM + template_version_presets tvp + INNER JOIN template_versions tv ON tvp.template_version_id = tv.id +WHERE tvp.id = @preset_id; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index d9f8ce275bfdf..2b91f38c88d42 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -103,6 +103,7 @@ const ( UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); + UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index bcf344fc56c3f..b9f303f95c319 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1856,9 +1856,11 @@ func InsertWorkspacePresetsAndParameters(ctx context.Context, logger slog.Logger func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store, templateVersionID uuid.UUID, protoPreset *sdkproto.Preset, t time.Time) error { err := db.InTx(func(tx database.Store) error { dbPreset, err := tx.InsertPreset(ctx, database.InsertPresetParams{ - TemplateVersionID: templateVersionID, - Name: protoPreset.Name, - CreatedAt: t, + TemplateVersionID: templateVersionID, + Name: protoPreset.Name, + CreatedAt: t, + DesiredInstances: sql.NullInt32{}, + InvalidateAfterSecs: sql.NullInt32{}, }) if err != nil { return xerrors.Errorf("insert preset: %w", err)