diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index de7cd416f287d..398ce80970a3c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6002,6 +6002,41 @@ const docTemplate = `{ } } }, + "/templates/{template}/prebuilds/invalidate": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Invalidate presets for template", + "operationId": "invalidate-presets-for-template", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Template ID", + "name": "template", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.InvalidatePresetsResponse" + } + } + } + } + }, "/templates/{template}/versions": { "get": { "security": [ @@ -14889,6 +14924,31 @@ const docTemplate = `{ "InsightsReportIntervalWeek" ] }, + "codersdk.InvalidatePresetsResponse": { + "type": "object", + "properties": { + "invalidated": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.InvalidatedPreset" + } + } + } + }, + "codersdk.InvalidatedPreset": { + "type": "object", + "properties": { + "preset_name": { + "type": "string" + }, + "template_name": { + "type": "string" + }, + "template_version_name": { + "type": "string" + } + } + }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 80d705f335f13..dfe1c793811c8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5309,6 +5309,37 @@ } } }, + "/templates/{template}/prebuilds/invalidate": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Invalidate presets for template", + "operationId": "invalidate-presets-for-template", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Template ID", + "name": "template", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.InvalidatePresetsResponse" + } + } + } + } + }, "/templates/{template}/versions": { "get": { "security": [ @@ -13487,6 +13518,31 @@ "InsightsReportIntervalWeek" ] }, + "codersdk.InvalidatePresetsResponse": { + "type": "object", + "properties": { + "invalidated": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.InvalidatedPreset" + } + } + } + }, + "codersdk.InvalidatedPreset": { + "type": "object", + "properties": { + "preset_name": { + "type": "string" + }, + "template_name": { + "type": "string" + }, + "template_version_name": { + "type": "string" + } + } + }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", "required": ["agentID", "url"], diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 3b97a991f7a03..8126ea435e838 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1021,6 +1021,18 @@ func AIBridgeToolUsage(usage database.AIBridgeToolUsage) codersdk.AIBridgeToolUs } } +func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset { + var presets []codersdk.InvalidatedPreset + for _, p := range invalidatedPresets { + presets = append(presets, codersdk.InvalidatedPreset{ + TemplateName: p.TemplateName, + TemplateVersionName: p.TemplateVersionName, + PresetName: p.TemplateVersionPresetName, + }) + } + return presets +} + func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any { var m map[string]any if !rawMessage.Valid { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 87b5de36009bf..7cc074b5ce752 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4972,6 +4972,20 @@ func (q *querier) UpdatePresetPrebuildStatus(ctx context.Context, arg database.U return q.db.UpdatePresetPrebuildStatus(ctx, arg) } +func (q *querier) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg database.UpdatePresetsLastInvalidatedAtParams) ([]database.UpdatePresetsLastInvalidatedAtRow, error) { + // Fetch template to check authorization + template, err := q.db.GetTemplateByID(ctx, arg.TemplateID) + if err != nil { + return nil, err + } + + if err := q.authorizeContext(ctx, policy.ActionUpdate, template); err != nil { + return nil, err + } + + return q.db.UpdatePresetsLastInvalidatedAt(ctx, arg) +} + func (q *querier) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceProvisionerDaemon); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index fc98700c548f6..4e2f02d0d3c81 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1315,6 +1315,13 @@ func (s *MethodTestSuite) TestTemplate() { dbm.EXPECT().UpsertTemplateUsageStats(gomock.Any()).Return(nil).AnyTimes() check.Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) + s.Run("UpdatePresetsLastInvalidatedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + t1 := testutil.Fake(s.T(), faker, database.Template{}) + arg := database.UpdatePresetsLastInvalidatedAtParams{LastInvalidatedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, TemplateID: t1.ID} + dbm.EXPECT().GetTemplateByID(gomock.Any(), t1.ID).Return(t1, nil).AnyTimes() + dbm.EXPECT().UpdatePresetsLastInvalidatedAt(gomock.Any(), arg).Return([]database.UpdatePresetsLastInvalidatedAtRow{}, nil).AnyTimes() + check.Args(arg).Asserts(t1, policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestUser() { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index a682ec838ffed..97558b4b8b928 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -613,6 +613,7 @@ func (t TemplateVersionBuilder) Do() TemplateVersionResponse { IsDefault: false, Description: preset.Description, Icon: preset.Icon, + LastInvalidatedAt: preset.LastInvalidatedAt, }) t.logger.Debug(context.Background(), "added preset", slog.F("preset_id", prst.ID), diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c325a9f484df1..0e958647666c5 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1428,6 +1428,7 @@ func Preset(t testing.TB, db database.Store, seed database.InsertPresetParams) d IsDefault: seed.IsDefault, Description: seed.Description, Icon: seed.Icon, + LastInvalidatedAt: seed.LastInvalidatedAt, }) require.NoError(t, err, "insert preset") return preset diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index d841315924a15..527db3953c2b6 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3070,6 +3070,13 @@ func (m queryMetricsStore) UpdatePresetPrebuildStatus(ctx context.Context, arg d return r0 } +func (m queryMetricsStore) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg database.UpdatePresetsLastInvalidatedAtParams) ([]database.UpdatePresetsLastInvalidatedAtRow, error) { + start := time.Now() + r0, r1 := m.s.UpdatePresetsLastInvalidatedAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdatePresetsLastInvalidatedAt").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { start := time.Now() r0 := m.s.UpdateProvisionerDaemonLastSeenAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 313bb988979a1..4906b695b2724 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6598,6 +6598,21 @@ func (mr *MockStoreMockRecorder) UpdatePresetPrebuildStatus(ctx, arg any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePresetPrebuildStatus", reflect.TypeOf((*MockStore)(nil).UpdatePresetPrebuildStatus), ctx, arg) } +// UpdatePresetsLastInvalidatedAt mocks base method. +func (m *MockStore) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg database.UpdatePresetsLastInvalidatedAtParams) ([]database.UpdatePresetsLastInvalidatedAtRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePresetsLastInvalidatedAt", ctx, arg) + ret0, _ := ret[0].([]database.UpdatePresetsLastInvalidatedAtRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdatePresetsLastInvalidatedAt indicates an expected call of UpdatePresetsLastInvalidatedAt. +func (mr *MockStoreMockRecorder) UpdatePresetsLastInvalidatedAt(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePresetsLastInvalidatedAt", reflect.TypeOf((*MockStore)(nil).UpdatePresetsLastInvalidatedAt), ctx, arg) +} + // UpdateProvisionerDaemonLastSeenAt mocks base method. func (m *MockStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e7b46c2f3445c..1a777abf95fc9 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2170,7 +2170,8 @@ CREATE TABLE template_version_presets ( scheduling_timezone text DEFAULT ''::text NOT NULL, is_default boolean DEFAULT false NOT NULL, description character varying(128) DEFAULT ''::character varying NOT NULL, - icon character varying(256) DEFAULT ''::character varying NOT NULL + icon character varying(256) DEFAULT ''::character varying NOT NULL, + last_invalidated_at timestamp with time zone ); COMMENT ON COLUMN template_version_presets.description IS 'Short text describing the preset (max 128 characters).'; diff --git a/coderd/database/migrations/000399_template_version_presets_last_invalidated_at.down.sql b/coderd/database/migrations/000399_template_version_presets_last_invalidated_at.down.sql new file mode 100644 index 0000000000000..d8f4efc31615f --- /dev/null +++ b/coderd/database/migrations/000399_template_version_presets_last_invalidated_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE template_version_presets DROP COLUMN last_invalidated_at; diff --git a/coderd/database/migrations/000399_template_version_presets_last_invalidated_at.up.sql b/coderd/database/migrations/000399_template_version_presets_last_invalidated_at.up.sql new file mode 100644 index 0000000000000..87488aa41c671 --- /dev/null +++ b/coderd/database/migrations/000399_template_version_presets_last_invalidated_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE template_version_presets ADD COLUMN last_invalidated_at TIMESTAMPTZ; diff --git a/coderd/database/models.go b/coderd/database/models.go index af901c31ccad5..10cd5a9c0392e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4452,7 +4452,8 @@ type TemplateVersionPreset struct { // Short text describing the preset (max 128 characters). Description string `db:"description" json:"description"` // URL or path to an icon representing the preset (max 256 characters). - Icon string `db:"icon" json:"icon"` + Icon string `db:"icon" json:"icon"` + LastInvalidatedAt sql.NullTime `db:"last_invalidated_at" json:"last_invalidated_at"` } type TemplateVersionPresetParameter struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3e5771f96de04..39fa7dab120bf 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -673,6 +673,7 @@ type sqlcQuerier interface { // This is an optimization to clean up stale pending jobs. UpdatePrebuildProvisionerJobWithCancel(ctx context.Context, arg UpdatePrebuildProvisionerJobWithCancelParams) ([]UpdatePrebuildProvisionerJobWithCancelRow, error) UpdatePresetPrebuildStatus(ctx context.Context, arg UpdatePresetPrebuildStatusParams) error + UpdatePresetsLastInvalidatedAt(ctx context.Context, arg UpdatePresetsLastInvalidatedAtParams) ([]UpdatePresetsLastInvalidatedAtRow, error) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobLogsLength(ctx context.Context, arg UpdateProvisionerJobLogsLengthParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 21cb7b1874b5e..557840db6794f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8709,6 +8709,7 @@ SELECT tvp.scheduling_timezone, tvp.invalidate_after_secs AS ttl, tvp.prebuild_status, + tvp.last_invalidated_at, t.deleted, t.deprecated != '' AS deprecated FROM templates t @@ -8734,6 +8735,7 @@ type GetTemplatePresetsWithPrebuildsRow struct { SchedulingTimezone string `db:"scheduling_timezone" json:"scheduling_timezone"` Ttl sql.NullInt32 `db:"ttl" json:"ttl"` PrebuildStatus PrebuildStatus `db:"prebuild_status" json:"prebuild_status"` + LastInvalidatedAt sql.NullTime `db:"last_invalidated_at" json:"last_invalidated_at"` Deleted bool `db:"deleted" json:"deleted"` Deprecated bool `db:"deprecated" json:"deprecated"` } @@ -8764,6 +8766,7 @@ func (q *sqlQuerier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templa &i.SchedulingTimezone, &i.Ttl, &i.PrebuildStatus, + &i.LastInvalidatedAt, &i.Deleted, &i.Deprecated, ); err != nil { @@ -8897,7 +8900,7 @@ func (q *sqlQuerier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]Te } const getPresetByID = `-- name: GetPresetByID :one -SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tvp.is_default, tvp.description, tvp.icon, tv.template_id, tv.organization_id FROM +SELECT tvp.id, tvp.template_version_id, tvp.name, tvp.created_at, tvp.desired_instances, tvp.invalidate_after_secs, tvp.prebuild_status, tvp.scheduling_timezone, tvp.is_default, tvp.description, tvp.icon, tvp.last_invalidated_at, 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 @@ -8915,6 +8918,7 @@ type GetPresetByIDRow struct { IsDefault bool `db:"is_default" json:"is_default"` Description string `db:"description" json:"description"` Icon string `db:"icon" json:"icon"` + LastInvalidatedAt sql.NullTime `db:"last_invalidated_at" json:"last_invalidated_at"` TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } @@ -8934,6 +8938,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get &i.IsDefault, &i.Description, &i.Icon, + &i.LastInvalidatedAt, &i.TemplateID, &i.OrganizationID, ) @@ -8942,7 +8947,7 @@ func (q *sqlQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (Get 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.desired_instances, template_version_presets.invalidate_after_secs, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone, template_version_presets.is_default, template_version_presets.description, template_version_presets.icon + 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, template_version_presets.prebuild_status, template_version_presets.scheduling_timezone, template_version_presets.is_default, template_version_presets.description, template_version_presets.icon, template_version_presets.last_invalidated_at FROM template_version_presets INNER JOIN workspace_builds ON workspace_builds.template_version_preset_id = template_version_presets.id @@ -8965,6 +8970,7 @@ func (q *sqlQuerier) GetPresetByWorkspaceBuildID(ctx context.Context, workspaceB &i.IsDefault, &i.Description, &i.Icon, + &i.LastInvalidatedAt, ) return i, err } @@ -9046,7 +9052,7 @@ func (q *sqlQuerier) GetPresetParametersByTemplateVersionID(ctx context.Context, const getPresetsByTemplateVersionID = `-- name: GetPresetsByTemplateVersionID :many SELECT - id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default, description, icon + id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default, description, icon, last_invalidated_at FROM template_version_presets WHERE @@ -9074,6 +9080,7 @@ func (q *sqlQuerier) GetPresetsByTemplateVersionID(ctx context.Context, template &i.IsDefault, &i.Description, &i.Icon, + &i.LastInvalidatedAt, ); err != nil { return nil, err } @@ -9099,7 +9106,8 @@ INSERT INTO template_version_presets ( scheduling_timezone, is_default, description, - icon + icon, + last_invalidated_at ) VALUES ( $1, @@ -9111,8 +9119,9 @@ VALUES ( $7, $8, $9, - $10 -) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default, description, icon + $10, + $11 +) RETURNING id, template_version_id, name, created_at, desired_instances, invalidate_after_secs, prebuild_status, scheduling_timezone, is_default, description, icon, last_invalidated_at ` type InsertPresetParams struct { @@ -9126,6 +9135,7 @@ type InsertPresetParams struct { IsDefault bool `db:"is_default" json:"is_default"` Description string `db:"description" json:"description"` Icon string `db:"icon" json:"icon"` + LastInvalidatedAt sql.NullTime `db:"last_invalidated_at" json:"last_invalidated_at"` } func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) (TemplateVersionPreset, error) { @@ -9140,6 +9150,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) ( arg.IsDefault, arg.Description, arg.Icon, + arg.LastInvalidatedAt, ) var i TemplateVersionPreset err := row.Scan( @@ -9154,6 +9165,7 @@ func (q *sqlQuerier) InsertPreset(ctx context.Context, arg InsertPresetParams) ( &i.IsDefault, &i.Description, &i.Icon, + &i.LastInvalidatedAt, ) return i, err } @@ -9249,6 +9261,57 @@ func (q *sqlQuerier) UpdatePresetPrebuildStatus(ctx context.Context, arg UpdateP return err } +const updatePresetsLastInvalidatedAt = `-- name: UpdatePresetsLastInvalidatedAt :many +UPDATE + template_version_presets tvp +SET + last_invalidated_at = $1 +FROM + templates t + JOIN template_versions tv ON tv.id = t.active_version_id +WHERE + t.id = $2 + AND tvp.template_version_id = tv.id +RETURNING + t.name AS template_name, + tv.name AS template_version_name, + tvp.name AS template_version_preset_name +` + +type UpdatePresetsLastInvalidatedAtParams struct { + LastInvalidatedAt sql.NullTime `db:"last_invalidated_at" json:"last_invalidated_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +type UpdatePresetsLastInvalidatedAtRow struct { + TemplateName string `db:"template_name" json:"template_name"` + TemplateVersionName string `db:"template_version_name" json:"template_version_name"` + TemplateVersionPresetName string `db:"template_version_preset_name" json:"template_version_preset_name"` +} + +func (q *sqlQuerier) UpdatePresetsLastInvalidatedAt(ctx context.Context, arg UpdatePresetsLastInvalidatedAtParams) ([]UpdatePresetsLastInvalidatedAtRow, error) { + rows, err := q.db.QueryContext(ctx, updatePresetsLastInvalidatedAt, arg.LastInvalidatedAt, arg.TemplateID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UpdatePresetsLastInvalidatedAtRow + for rows.Next() { + var i UpdatePresetsLastInvalidatedAtRow + if err := rows.Scan(&i.TemplateName, &i.TemplateVersionName, &i.TemplateVersionPresetName); 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 deleteOldProvisionerDaemons = `-- name: DeleteOldProvisionerDaemons :exec DELETE FROM provisioner_daemons WHERE ( (created_at < (NOW() - INTERVAL '7 days') AND last_seen_at IS NULL) OR diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index ae70593b269d9..9dd68e8297314 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -51,6 +51,7 @@ SELECT tvp.scheduling_timezone, tvp.invalidate_after_secs AS ttl, tvp.prebuild_status, + tvp.last_invalidated_at, t.deleted, t.deprecated != '' AS deprecated FROM templates t diff --git a/coderd/database/queries/presets.sql b/coderd/database/queries/presets.sql index e6edcb4c59c1f..314c74b668657 100644 --- a/coderd/database/queries/presets.sql +++ b/coderd/database/queries/presets.sql @@ -9,7 +9,8 @@ INSERT INTO template_version_presets ( scheduling_timezone, is_default, description, - icon + icon, + last_invalidated_at ) VALUES ( @id, @@ -21,7 +22,8 @@ VALUES ( @scheduling_timezone, @is_default, @description, - @icon + @icon, + @last_invalidated_at ) RETURNING *; -- name: InsertPresetParameters :many @@ -103,3 +105,19 @@ WHERE tv.id = t.active_version_id AND NOT t.deleted AND t.deprecated = ''; + +-- name: UpdatePresetsLastInvalidatedAt :many +UPDATE + template_version_presets tvp +SET + last_invalidated_at = @last_invalidated_at +FROM + templates t + JOIN template_versions tv ON tv.id = t.active_version_id +WHERE + t.id = @template_id + AND tvp.template_version_id = tv.id +RETURNING + t.name AS template_name, + tv.name AS template_version_name, + tvp.name AS template_version_preset_name; diff --git a/coderd/prebuilds/global_snapshot.go b/coderd/prebuilds/global_snapshot.go index 3c7ec24f5644b..cb91658707c1b 100644 --- a/coderd/prebuilds/global_snapshot.go +++ b/coderd/prebuilds/global_snapshot.go @@ -125,20 +125,29 @@ func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool { } // filterExpiredWorkspaces splits running workspaces into expired and non-expired -// based on the preset's TTL. -// If TTL is missing or zero, all workspaces are considered non-expired. +// based on the preset's TTL and last_invalidated_at timestamp. +// A prebuild is considered expired if: +// 1. The preset has been invalidated (last_invalidated_at is set), OR +// 2. It exceeds the preset's TTL (if TTL is set) +// If TTL is missing or zero, only last_invalidated_at is checked. func filterExpiredWorkspaces(preset database.GetTemplatePresetsWithPrebuildsRow, runningWorkspaces []database.GetRunningPrebuiltWorkspacesRow) (nonExpired []database.GetRunningPrebuiltWorkspacesRow, expired []database.GetRunningPrebuiltWorkspacesRow) { - if !preset.Ttl.Valid { - return runningWorkspaces, expired - } + for _, prebuild := range runningWorkspaces { + isExpired := false - ttl := time.Duration(preset.Ttl.Int32) * time.Second - if ttl <= 0 { - return runningWorkspaces, expired - } + // Check if prebuild was created before last invalidation + if preset.LastInvalidatedAt.Valid && prebuild.CreatedAt.Before(preset.LastInvalidatedAt.Time) { + isExpired = true + } - for _, prebuild := range runningWorkspaces { - if time.Since(prebuild.CreatedAt) > ttl { + // Check TTL expiration if set + if !isExpired && preset.Ttl.Valid { + ttl := time.Duration(preset.Ttl.Int32) * time.Second + if ttl > 0 && time.Since(prebuild.CreatedAt) > ttl { + isExpired = true + } + } + + if isExpired { expired = append(expired, prebuild) } else { nonExpired = append(nonExpired, prebuild) diff --git a/coderd/prebuilds/preset_snapshot_test.go b/coderd/prebuilds/preset_snapshot_test.go index c32a84777d069..ebc8921430861 100644 --- a/coderd/prebuilds/preset_snapshot_test.go +++ b/coderd/prebuilds/preset_snapshot_test.go @@ -600,6 +600,9 @@ func TestExpiredPrebuilds(t *testing.T) { running int32 desired int32 expired int32 + + invalidated int32 + checkFn func(runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow, state prebuilds.ReconciliationState, actions []*prebuilds.ReconciliationActions) }{ // With 2 running prebuilds, none of which are expired, and the desired count is met, @@ -708,6 +711,52 @@ func TestExpiredPrebuilds(t *testing.T) { }, } + validateState(t, expectedState, state) + validateActions(t, expectedActions, actions) + }, + }, + { + name: "preset has been invalidated - both instances expired", + running: 2, + desired: 2, + expired: 0, + invalidated: 2, + checkFn: func(runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow, state prebuilds.ReconciliationState, actions []*prebuilds.ReconciliationActions) { + expectedState := prebuilds.ReconciliationState{Actual: 2, Desired: 2, Expired: 2} + expectedActions := []*prebuilds.ReconciliationActions{ + { + ActionType: prebuilds.ActionTypeDelete, + DeleteIDs: []uuid.UUID{runningPrebuilds[0].ID, runningPrebuilds[1].ID}, + }, + { + ActionType: prebuilds.ActionTypeCreate, + Create: 2, + }, + } + + validateState(t, expectedState, state) + validateActions(t, expectedActions, actions) + }, + }, + { + name: "preset has been invalidated, but one prebuild instance is newer", + running: 2, + desired: 2, + expired: 0, + invalidated: 1, + checkFn: func(runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow, state prebuilds.ReconciliationState, actions []*prebuilds.ReconciliationActions) { + expectedState := prebuilds.ReconciliationState{Actual: 2, Desired: 2, Expired: 1} + expectedActions := []*prebuilds.ReconciliationActions{ + { + ActionType: prebuilds.ActionTypeDelete, + DeleteIDs: []uuid.UUID{runningPrebuilds[0].ID}, + }, + { + ActionType: prebuilds.ActionTypeCreate, + Create: 1, + }, + } + validateState(t, expectedState, state) validateActions(t, expectedActions, actions) }, @@ -719,7 +768,17 @@ func TestExpiredPrebuilds(t *testing.T) { t.Parallel() // GIVEN: a preset. - defaultPreset := preset(true, tc.desired, current) + now := time.Now() + invalidatedAt := now.Add(1 * time.Minute) + + var muts []func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow + if tc.invalidated > 0 { + muts = append(muts, func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow { + row.LastInvalidatedAt = sql.NullTime{Valid: true, Time: invalidatedAt} + return row + }) + } + defaultPreset := preset(true, tc.desired, current, muts...) presets := []database.GetTemplatePresetsWithPrebuildsRow{ defaultPreset, } @@ -727,11 +786,22 @@ func TestExpiredPrebuilds(t *testing.T) { // GIVEN: running prebuilt workspaces for the preset. running := make([]database.GetRunningPrebuiltWorkspacesRow, 0, tc.running) expiredCount := 0 + invalidatedCount := 0 ttlDuration := time.Duration(defaultPreset.Ttl.Int32) for range tc.running { name, err := prebuilds.GenerateName() require.NoError(t, err) + prebuildCreateAt := time.Now() + if int(tc.invalidated) > invalidatedCount { + prebuildCreateAt = prebuildCreateAt.Add(-ttlDuration - 10*time.Second) + invalidatedCount++ + } else if invalidatedCount > 0 { + // Only `tc.invalidated` instances have been invalidated, + // so the next instance is assumed to be created after `invalidatedAt`. + prebuildCreateAt = invalidatedAt.Add(1 * time.Minute) + } + if int(tc.expired) > expiredCount { // Update the prebuild workspace createdAt to exceed its TTL (5 seconds) prebuildCreateAt = prebuildCreateAt.Add(-ttlDuration - 10*time.Second) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 39e707a87f016..c4598beaf8399 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -2581,6 +2581,7 @@ func InsertWorkspacePresetAndParameters(ctx context.Context, db database.Store, IsDefault: protoPreset.GetDefault(), Description: protoPreset.Description, Icon: protoPreset.Icon, + LastInvalidatedAt: sql.NullTime{}, }) if err != nil { return xerrors.Errorf("insert preset: %w", err) diff --git a/codersdk/templates.go b/codersdk/templates.go index a96dcb495dad8..36d57521c595d 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -513,3 +513,34 @@ func (c *Client) StarterTemplates(ctx context.Context) ([]TemplateExample, error var templateExamples []TemplateExample return templateExamples, json.NewDecoder(res.Body).Decode(&templateExamples) } + +type InvalidatePresetsResponse struct { + Invalidated []InvalidatedPreset `json:"invalidated"` +} + +type InvalidatedPreset struct { + TemplateName string `json:"template_name"` + TemplateVersionName string `json:"template_version_name"` + PresetName string `json:"preset_name"` +} + +// InvalidateTemplatePresets invalidates all presets for the +// template's active version by setting last_invalidated_at timestamp. +// The reconciler will then mark these prebuilds as expired and create new ones. +func (c *Client) InvalidateTemplatePresets(ctx context.Context, template uuid.UUID) (InvalidatePresetsResponse, error) { + res, err := c.Request(ctx, http.MethodPost, + fmt.Sprintf("/api/v2/templates/%s/prebuilds/invalidate", template), + nil, + ) + if err != nil { + return InvalidatePresetsResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return InvalidatePresetsResponse{}, ReadBodyAsError(res) + } + + var response InvalidatePresetsResponse + return response, json.NewDecoder(res.Body).Decode(&response) +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 5ab32d8a3f43b..dfdeeb1756ef7 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -3788,6 +3788,49 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Invalidate presets for template + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/templates/{template}/prebuilds/invalidate \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /templates/{template}/prebuilds/invalidate` + +### Parameters + +| Name | In | Type | Required | Description | +|------------|------|--------------|----------|-------------| +| `template` | path | string(uuid) | true | Template ID | + +### Example responses + +> 200 Response + +```json +{ + "invalidated": [ + { + "preset_name": "string", + "template_name": "string", + "template_version_name": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.InvalidatePresetsResponse](schemas.md#codersdkinvalidatepresetsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get user quiet hours schedule ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 0f43255ad60c7..3c7bcab5da580 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4715,6 +4715,44 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `day` | | `week` | +## codersdk.InvalidatePresetsResponse + +```json +{ + "invalidated": [ + { + "preset_name": "string", + "template_name": "string", + "template_version_name": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|-------------------------------------------------------------------|----------|--------------|-------------| +| `invalidated` | array of [codersdk.InvalidatedPreset](#codersdkinvalidatedpreset) | false | | | + +## codersdk.InvalidatedPreset + +```json +{ + "preset_name": "string", + "template_name": "string", + "template_version_name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------------|--------|----------|--------------|-------------| +| `preset_name` | string | false | | | +| `template_name` | string | false | | | +| `template_version_name` | string | false | | | + ## codersdk.IssueReconnectingPTYSignedTokenRequest ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 00a78c0bd9069..9a7b1f318f7c2 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -458,6 +458,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.templateACL) r.Patch("/", api.patchTemplateACL) }) + r.Route("/templates/{template}/prebuilds", func(r chi.Router) { + r.Use( + api.templateRBACEnabledMW, + apiKeyMiddleware, + httpmw.ExtractTemplateParam(api.Database), + ) + r.Post("/invalidate", api.postInvalidateTemplatePresets) + }) + r.Route("/groups", func(r chi.Router) { r.Use( api.templateRBACEnabledMW, diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index 16f2e7fc4fac9..ff74d9035c1b8 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -338,3 +340,45 @@ func (api *API) RequireFeatureMW(feat codersdk.FeatureName) func(http.Handler) h }) } } + +// @Summary Invalidate presets for template +// @ID invalidate-presets-for-template +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param template path string true "Template ID" format(uuid) +// @Success 200 {object} codersdk.InvalidatePresetsResponse +// @Router /templates/{template}/prebuilds/invalidate [post] +func (api *API) postInvalidateTemplatePresets(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + template := httpmw.TemplateParam(r) + + // Authorization: user must be able to update the template + if !api.Authorize(r, policy.ActionUpdate, template) { + httpapi.ResourceNotFound(rw) + return + } + + // Update last_invalidated_at for all presets of the active template version + invalidatedPresets, err := api.Database.UpdatePresetsLastInvalidatedAt(ctx, database.UpdatePresetsLastInvalidatedAtParams{ + TemplateID: template.ID, + LastInvalidatedAt: sql.NullTime{Time: api.Clock.Now(), Valid: true}, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to invalidate presets.", + Detail: err.Error(), + }) + return + } + + api.Logger.Info(ctx, "invalidated presets", + slog.F("template_id", template.ID), + slog.F("template_name", template.Name), + slog.F("preset_count", len(invalidatedPresets)), + ) + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.InvalidatePresetsResponse{ + Invalidated: db2sdk.InvalidatedPresets(invalidatedPresets), + }) +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index e5eafa82f8d1c..f9c431b6446f4 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -2111,3 +2111,100 @@ func TestMultipleOrganizationTemplates(t *testing.T) { t.FailNow() } } + +func TestInvalidateTemplatePrebuilds(t *testing.T) { + t.Parallel() + + // Given the following parameters and presets... + templateVersionParameters := []*proto.RichParameter{ + {Name: "param1", Type: "string", Required: false, DefaultValue: "default1"}, + {Name: "param2", Type: "string", Required: false, DefaultValue: "default2"}, + {Name: "param3", Type: "string", Required: false, DefaultValue: "default3"}, + } + presetWithParameters1 := &proto.Preset{ + Name: "Preset With Parameters 1", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value1"}, + {Name: "param2", Value: "value2"}, + {Name: "param3", Value: "value3"}, + }, + } + presetWithParameters2 := &proto.Preset{ + Name: "Preset With Parameters 2", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value4"}, + {Name: "param2", Value: "value5"}, + {Name: "param3", Value: "value6"}, + }, + } + + presetWithParameters3 := &proto.Preset{ + Name: "Preset With Parameters 3", + Parameters: []*proto.PresetParameter{ + {Name: "param1", Value: "value7"}, + {Name: "param2", Value: "value8"}, + {Name: "param3", Value: "value9"}, + }, + } + + // Given the template versions and template... + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + buildPlanResponse := func(presets ...*proto.Preset) *proto.Response { + return &proto.Response{ + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Presets: presets, + Parameters: templateVersionParameters, + }, + }, + } + } + + version1 := coderdtest.CreateTemplateVersion(t, templateAdminClient, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{buildPlanResponse(presetWithParameters1, presetWithParameters2)}, + ProvisionApply: echo.ApplyComplete, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdminClient, version1.ID) + template := coderdtest.CreateTemplate(t, templateAdminClient, owner.OrganizationID, version1.ID) + + // When + ctx := testutil.Context(t, testutil.WaitLong) + invalidated, err := templateAdminClient.InvalidateTemplatePresets(ctx, template.ID) + require.NoError(t, err) + + // Then + require.Len(t, invalidated.Invalidated, 2) + require.Equal(t, codersdk.InvalidatedPreset{TemplateName: template.Name, TemplateVersionName: version1.Name, PresetName: presetWithParameters1.Name}, invalidated.Invalidated[0]) + require.Equal(t, codersdk.InvalidatedPreset{TemplateName: template.Name, TemplateVersionName: version1.Name, PresetName: presetWithParameters2.Name}, invalidated.Invalidated[1]) + + // Given the template is updated... + version2 := coderdtest.UpdateTemplateVersion(t, templateAdminClient, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{buildPlanResponse(presetWithParameters2, presetWithParameters3)}, + ProvisionApply: echo.ApplyComplete, + }, template.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdminClient, version2.ID) + err = templateAdminClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ID: version2.ID}) + require.NoError(t, err) + + // When + invalidated, err = templateAdminClient.InvalidateTemplatePresets(ctx, template.ID) + require.NoError(t, err) + + // Then: it should only invalidate the presets from the currently active version (preset2 and preset3) + require.Len(t, invalidated.Invalidated, 2) + require.Equal(t, codersdk.InvalidatedPreset{TemplateName: template.Name, TemplateVersionName: version2.Name, PresetName: presetWithParameters2.Name}, invalidated.Invalidated[0]) + require.Equal(t, codersdk.InvalidatedPreset{TemplateName: template.Name, TemplateVersionName: version2.Name, PresetName: presetWithParameters3.Name}, invalidated.Invalidated[1]) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c2c94aa314b3d..db4f3b2865d05 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2481,6 +2481,18 @@ export const InsightsReportIntervals: InsightsReportInterval[] = [ "week", ]; +// From codersdk/templates.go +export interface InvalidatePresetsResponse { + readonly invalidated: readonly InvalidatedPreset[]; +} + +// From codersdk/templates.go +export interface InvalidatedPreset { + readonly template_name: string; + readonly template_version_name: string; + readonly preset_name: string; +} + // From codersdk/workspaceagents.go export interface IssueReconnectingPTYSignedTokenRequest { /**