diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 745ea3e3ff4b1..4b73d143412eb 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -4219,6 +4219,71 @@ const docTemplate = `{
}
}
},
+ "/prebuilds/settings": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Prebuilds"
+ ],
+ "summary": "Get prebuilds settings",
+ "operationId": "get-prebuilds-settings",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.PrebuildsSettings"
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Prebuilds"
+ ],
+ "summary": "Update prebuilds settings",
+ "operationId": "update-prebuilds-settings",
+ "parameters": [
+ {
+ "description": "Prebuilds settings request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.PrebuildsSettings"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.PrebuildsSettings"
+ }
+ },
+ "304": {
+ "description": "Not Modified"
+ }
+ }
+ }
+ },
"/provisionerkeys/{provisionerkey}": {
"get": {
"security": [
@@ -13934,6 +13999,14 @@ const docTemplate = `{
}
}
},
+ "codersdk.PrebuildsSettings": {
+ "type": "object",
+ "properties": {
+ "reconciliation_paused": {
+ "type": "boolean"
+ }
+ }
+ },
"codersdk.Preset": {
"type": "object",
"properties": {
@@ -14918,6 +14991,7 @@ const docTemplate = `{
"convert_login",
"health_settings",
"notifications_settings",
+ "prebuilds_settings",
"workspace_proxy",
"organization",
"oauth2_provider_app",
@@ -14944,6 +15018,7 @@ const docTemplate = `{
"ResourceTypeConvertLogin",
"ResourceTypeHealthSettings",
"ResourceTypeNotificationsSettings",
+ "ResourceTypePrebuildsSettings",
"ResourceTypeWorkspaceProxy",
"ResourceTypeOrganization",
"ResourceTypeOAuth2ProviderApp",
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index d782946aa467a..21e6d070388f6 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -3732,6 +3732,61 @@
}
}
},
+ "/prebuilds/settings": {
+ "get": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "produces": ["application/json"],
+ "tags": ["Prebuilds"],
+ "summary": "Get prebuilds settings",
+ "operationId": "get-prebuilds-settings",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.PrebuildsSettings"
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ],
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["Prebuilds"],
+ "summary": "Update prebuilds settings",
+ "operationId": "update-prebuilds-settings",
+ "parameters": [
+ {
+ "description": "Prebuilds settings request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.PrebuildsSettings"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/codersdk.PrebuildsSettings"
+ }
+ },
+ "304": {
+ "description": "Not Modified"
+ }
+ }
+ }
+ },
"/provisionerkeys/{provisionerkey}": {
"get": {
"security": [
@@ -12600,6 +12655,14 @@
}
}
},
+ "codersdk.PrebuildsSettings": {
+ "type": "object",
+ "properties": {
+ "reconciliation_paused": {
+ "type": "boolean"
+ }
+ }
+ },
"codersdk.Preset": {
"type": "object",
"properties": {
@@ -13540,6 +13603,7 @@
"convert_login",
"health_settings",
"notifications_settings",
+ "prebuilds_settings",
"workspace_proxy",
"organization",
"oauth2_provider_app",
@@ -13566,6 +13630,7 @@
"ResourceTypeConvertLogin",
"ResourceTypeHealthSettings",
"ResourceTypeNotificationsSettings",
+ "ResourceTypePrebuildsSettings",
"ResourceTypeWorkspaceProxy",
"ResourceTypeOrganization",
"ResourceTypeOAuth2ProviderApp",
diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index 39d13ff789efc..56ac9f88ccaae 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -24,6 +24,7 @@ type Auditable interface {
database.NotificationsSettings |
database.OAuth2ProviderApp |
database.OAuth2ProviderAppSecret |
+ database.PrebuildsSettings |
database.CustomRole |
database.AuditableOrganizationMember |
database.Organization |
diff --git a/coderd/audit/request.go b/coderd/audit/request.go
index fd755e39c5216..0fa88fa40e2ea 100644
--- a/coderd/audit/request.go
+++ b/coderd/audit/request.go
@@ -113,6 +113,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return "" // no target?
case database.NotificationsSettings:
return "" // no target?
+ case database.PrebuildsSettings:
+ return "" // no target?
case database.OAuth2ProviderApp:
return typed.Name
case database.OAuth2ProviderAppSecret:
@@ -176,6 +178,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
case database.NotificationsSettings:
// Artificial ID for auditing purposes
return typed.ID
+ case database.PrebuildsSettings:
+ // Artificial ID for auditing purposes
+ return typed.ID
case database.OAuth2ProviderApp:
return typed.ID
case database.OAuth2ProviderAppSecret:
@@ -231,6 +236,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeHealthSettings
case database.NotificationsSettings:
return database.ResourceTypeNotificationsSettings
+ case database.PrebuildsSettings:
+ return database.ResourceTypePrebuildsSettings
case database.OAuth2ProviderApp:
return database.ResourceTypeOauth2ProviderApp
case database.OAuth2ProviderAppSecret:
@@ -288,6 +295,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
case database.NotificationsSettings:
// Artificial ID for auditing purposes
return false
+ case database.PrebuildsSettings:
+ // Artificial ID for auditing purposes
+ return false
case database.OAuth2ProviderApp:
return false
case database.OAuth2ProviderAppSecret:
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index a2c3b1d5705da..0c257c62de41a 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -2304,6 +2304,10 @@ func (q *querier) GetPrebuildMetrics(ctx context.Context) ([]database.GetPrebuil
return q.db.GetPrebuildMetrics(ctx)
}
+func (q *querier) GetPrebuildsSettings(ctx context.Context) (string, error) {
+ return q.db.GetPrebuildsSettings(ctx)
+}
+
func (q *querier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) {
empty := database.GetPresetByIDRow{}
@@ -5101,6 +5105,13 @@ func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error
return q.db.UpsertOAuthSigningKey(ctx, value)
}
+func (q *querier) UpsertPrebuildsSettings(ctx context.Context, value string) error {
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
+ return err
+ }
+ return q.db.UpsertPrebuildsSettings(ctx, value)
+}
+
func (q *querier) UpsertProvisionerDaemon(ctx context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) {
res := rbac.ResourceProvisionerDaemon.InOrg(arg.OrganizationID)
if arg.Tags[provisionersdk.TagScope] == provisionersdk.ScopeUser {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index e83b2bd4710c5..9814acfc7223b 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -5071,6 +5071,12 @@ func (s *MethodTestSuite) TestPrebuilds() {
check.Args().
Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead)
}))
+ s.Run("GetPrebuildsSettings", s.Subtest(func(db database.Store, check *expects) {
+ check.Args().Asserts()
+ }))
+ s.Run("UpsertPrebuildsSettings", s.Subtest(func(db database.Store, check *expects) {
+ check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
+ }))
s.Run("CountInProgressPrebuilds", s.Subtest(func(_ database.Store, check *expects) {
check.Args().
Asserts(rbac.ResourceWorkspace.All(), policy.ActionRead).
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 42d61244d9098..49e19d44f1846 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -297,6 +297,7 @@ type data struct {
presets []database.TemplateVersionPreset
presetParameters []database.TemplateVersionPresetParameter
presetPrebuildSchedules []database.TemplateVersionPresetPrebuildSchedule
+ prebuildsSettings []byte
}
func tryPercentileCont(fs []float64, p float64) float64 {
@@ -4277,7 +4278,14 @@ func (*FakeQuerier) GetPrebuildMetrics(_ context.Context) ([]database.GetPrebuil
return make([]database.GetPrebuildMetricsRow, 0), nil
}
-func (q *FakeQuerier) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) {
+func (q *FakeQuerier) GetPrebuildsSettings(_ context.Context) (string, error) {
+ q.mutex.RLock()
+ defer q.mutex.RUnlock()
+
+ return string(slices.Clone(q.prebuildsSettings)), nil
+}
+
+func (q *FakeQuerier) GetPresetByID(_ context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -12313,6 +12321,14 @@ func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) err
return nil
}
+func (q *FakeQuerier) UpsertPrebuildsSettings(_ context.Context, value string) error {
+ q.mutex.Lock()
+ defer q.mutex.Unlock()
+
+ q.prebuildsSettings = []byte(value)
+ return nil
+}
+
func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) {
if err := validateDatabaseType(arg); err != nil {
return database.ProvisionerDaemon{}, err
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index ca2b0c2ce7fa5..ddfbb796a90dc 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -1103,6 +1103,13 @@ func (m queryMetricsStore) GetPrebuildMetrics(ctx context.Context) ([]database.G
return r0, r1
}
+func (m queryMetricsStore) GetPrebuildsSettings(ctx context.Context) (string, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetPrebuildsSettings(ctx)
+ m.queryLatencies.WithLabelValues("GetPrebuildsSettings").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)
@@ -3175,6 +3182,13 @@ func (m queryMetricsStore) UpsertOAuthSigningKey(ctx context.Context, value stri
return r0
}
+func (m queryMetricsStore) UpsertPrebuildsSettings(ctx context.Context, value string) error {
+ start := time.Now()
+ r0 := m.s.UpsertPrebuildsSettings(ctx, value)
+ m.queryLatencies.WithLabelValues("UpsertPrebuildsSettings").Observe(time.Since(start).Seconds())
+ return r0
+}
+
func (m queryMetricsStore) UpsertProvisionerDaemon(ctx context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) {
start := time.Now()
r0, r1 := m.s.UpsertProvisionerDaemon(ctx, arg)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index 9d7d6c74cb0ce..fb61d8e2df4f0 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -2283,6 +2283,21 @@ func (mr *MockStoreMockRecorder) GetPrebuildMetrics(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrebuildMetrics", reflect.TypeOf((*MockStore)(nil).GetPrebuildMetrics), ctx)
}
+// GetPrebuildsSettings mocks base method.
+func (m *MockStore) GetPrebuildsSettings(ctx context.Context) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPrebuildsSettings", ctx)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPrebuildsSettings indicates an expected call of GetPrebuildsSettings.
+func (mr *MockStoreMockRecorder) GetPrebuildsSettings(ctx any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrebuildsSettings", reflect.TypeOf((*MockStore)(nil).GetPrebuildsSettings), ctx)
+}
+
// GetPresetByID mocks base method.
func (m *MockStore) GetPresetByID(ctx context.Context, presetID uuid.UUID) (database.GetPresetByIDRow, error) {
m.ctrl.T.Helper()
@@ -6719,6 +6734,20 @@ func (mr *MockStoreMockRecorder) UpsertOAuthSigningKey(ctx, value any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertOAuthSigningKey", reflect.TypeOf((*MockStore)(nil).UpsertOAuthSigningKey), ctx, value)
}
+// UpsertPrebuildsSettings mocks base method.
+func (m *MockStore) UpsertPrebuildsSettings(ctx context.Context, value string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpsertPrebuildsSettings", ctx, value)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpsertPrebuildsSettings indicates an expected call of UpsertPrebuildsSettings.
+func (mr *MockStoreMockRecorder) UpsertPrebuildsSettings(ctx, value any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertPrebuildsSettings", reflect.TypeOf((*MockStore)(nil).UpsertPrebuildsSettings), ctx, value)
+}
+
// UpsertProvisionerDaemon mocks base method.
func (m *MockStore) UpsertProvisionerDaemon(ctx context.Context, arg database.UpsertProvisionerDaemonParams) (database.ProvisionerDaemon, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 480780c5fb556..df9643267e0fc 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -242,7 +242,8 @@ CREATE TYPE resource_type AS ENUM (
'idp_sync_settings_group',
'idp_sync_settings_role',
'workspace_agent',
- 'workspace_app'
+ 'workspace_app',
+ 'prebuilds_settings'
);
CREATE TYPE startup_script_behavior AS ENUM (
diff --git a/coderd/database/migrations/000345_audit_prebuilds_settings.down.sql b/coderd/database/migrations/000345_audit_prebuilds_settings.down.sql
new file mode 100644
index 0000000000000..35020b349fc4e
--- /dev/null
+++ b/coderd/database/migrations/000345_audit_prebuilds_settings.down.sql
@@ -0,0 +1 @@
+-- No-op, enum values can't be dropped.
diff --git a/coderd/database/migrations/000345_audit_prebuilds_settings.up.sql b/coderd/database/migrations/000345_audit_prebuilds_settings.up.sql
new file mode 100644
index 0000000000000..bbc4262eb1b64
--- /dev/null
+++ b/coderd/database/migrations/000345_audit_prebuilds_settings.up.sql
@@ -0,0 +1,2 @@
+ALTER TYPE resource_type
+ ADD VALUE IF NOT EXISTS 'prebuilds_settings';
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 634b5dcd4116d..9dcf665d034c1 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -1894,6 +1894,7 @@ const (
ResourceTypeIdpSyncSettingsRole ResourceType = "idp_sync_settings_role"
ResourceTypeWorkspaceAgent ResourceType = "workspace_agent"
ResourceTypeWorkspaceApp ResourceType = "workspace_app"
+ ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings"
)
func (e *ResourceType) Scan(src interface{}) error {
@@ -1956,7 +1957,8 @@ func (e ResourceType) Valid() bool {
ResourceTypeIdpSyncSettingsGroup,
ResourceTypeIdpSyncSettingsRole,
ResourceTypeWorkspaceAgent,
- ResourceTypeWorkspaceApp:
+ ResourceTypeWorkspaceApp,
+ ResourceTypePrebuildsSettings:
return true
}
return false
@@ -1988,6 +1990,7 @@ func AllResourceTypeValues() []ResourceType {
ResourceTypeIdpSyncSettingsRole,
ResourceTypeWorkspaceAgent,
ResourceTypeWorkspaceApp,
+ ResourceTypePrebuildsSettings,
}
}
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 4d5052b42aadc..b3696046ddd6e 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -236,6 +236,7 @@ type sqlcQuerier interface {
GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error)
GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error)
GetPrebuildMetrics(ctx context.Context) ([]GetPrebuildMetricsRow, error)
+ GetPrebuildsSettings(ctx context.Context) (string, error)
GetPresetByID(ctx context.Context, presetID uuid.UUID) (GetPresetByIDRow, error)
GetPresetByWorkspaceBuildID(ctx context.Context, workspaceBuildID uuid.UUID) (TemplateVersionPreset, error)
GetPresetParametersByPresetID(ctx context.Context, presetID uuid.UUID) ([]TemplateVersionPresetParameter, error)
@@ -651,6 +652,7 @@ type sqlcQuerier interface {
UpsertNotificationsSettings(ctx context.Context, value string) error
UpsertOAuth2GithubDefaultEligible(ctx context.Context, eligible bool) error
UpsertOAuthSigningKey(ctx context.Context, value string) error
+ UpsertPrebuildsSettings(ctx context.Context, value string) error
UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error)
UpsertRuntimeConfig(ctx context.Context, arg UpsertRuntimeConfigParams) error
UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error)
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index f07fddb68d8c0..fd7d14003c81d 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -9578,6 +9578,18 @@ func (q *sqlQuerier) GetOAuthSigningKey(ctx context.Context) (string, error) {
return value, err
}
+const getPrebuildsSettings = `-- name: GetPrebuildsSettings :one
+SELECT
+ COALESCE((SELECT value FROM site_configs WHERE key = 'prebuilds_settings'), '{}') :: text AS prebuilds_settings
+`
+
+func (q *sqlQuerier) GetPrebuildsSettings(ctx context.Context) (string, error) {
+ row := q.db.QueryRowContext(ctx, getPrebuildsSettings)
+ var prebuilds_settings string
+ err := row.Scan(&prebuilds_settings)
+ return prebuilds_settings, err
+}
+
const getRuntimeConfig = `-- name: GetRuntimeConfig :one
SELECT value FROM site_configs WHERE site_configs.key = $1
`
@@ -9760,6 +9772,16 @@ func (q *sqlQuerier) UpsertOAuthSigningKey(ctx context.Context, value string) er
return err
}
+const upsertPrebuildsSettings = `-- name: UpsertPrebuildsSettings :exec
+INSERT INTO site_configs (key, value) VALUES ('prebuilds_settings', $1)
+ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'prebuilds_settings'
+`
+
+func (q *sqlQuerier) UpsertPrebuildsSettings(ctx context.Context, value string) error {
+ _, err := q.db.ExecContext(ctx, upsertPrebuildsSettings, value)
+ return err
+}
+
const upsertRuntimeConfig = `-- name: UpsertRuntimeConfig :exec
INSERT INTO site_configs (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2 WHERE site_configs.key = $1
diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql
index 7ea0e7b001807..4ee19c6bd57f6 100644
--- a/coderd/database/queries/siteconfig.sql
+++ b/coderd/database/queries/siteconfig.sql
@@ -96,6 +96,15 @@ SELECT
INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings';
+-- name: GetPrebuildsSettings :one
+SELECT
+ COALESCE((SELECT value FROM site_configs WHERE key = 'prebuilds_settings'), '{}') :: text AS prebuilds_settings
+;
+
+-- name: UpsertPrebuildsSettings :exec
+INSERT INTO site_configs (key, value) VALUES ('prebuilds_settings', $1)
+ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'prebuilds_settings';
+
-- name: GetRuntimeConfig :one
SELECT value FROM site_configs WHERE site_configs.key = $1;
diff --git a/coderd/database/types.go b/coderd/database/types.go
index 2528a30aa3fe8..a4a723d02b466 100644
--- a/coderd/database/types.go
+++ b/coderd/database/types.go
@@ -35,6 +35,11 @@ type NotificationsSettings struct {
NotifierPaused bool `db:"notifier_paused" json:"notifier_paused"`
}
+type PrebuildsSettings struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ ReconciliationPaused bool `db:"reconciliation_paused" json:"reconciliation_paused"`
+}
+
type Actions []policy.Action
func (a *Actions) Scan(src interface{}) error {
diff --git a/codersdk/audit.go b/codersdk/audit.go
index 12a35904a8af4..49e597845b964 100644
--- a/codersdk/audit.go
+++ b/codersdk/audit.go
@@ -26,6 +26,7 @@ const (
ResourceTypeConvertLogin ResourceType = "convert_login"
ResourceTypeHealthSettings ResourceType = "health_settings"
ResourceTypeNotificationsSettings ResourceType = "notifications_settings"
+ ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings"
ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy"
ResourceTypeOrganization ResourceType = "organization"
ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app"
@@ -73,6 +74,8 @@ func (r ResourceType) FriendlyString() string {
return "health_settings"
case ResourceTypeNotificationsSettings:
return "notifications_settings"
+ case ResourceTypePrebuildsSettings:
+ return "prebuilds_settings"
case ResourceTypeOAuth2ProviderApp:
return "oauth2 app"
case ResourceTypeOAuth2ProviderAppSecret:
diff --git a/codersdk/prebuilds.go b/codersdk/prebuilds.go
new file mode 100644
index 0000000000000..1f428d2f75b8c
--- /dev/null
+++ b/codersdk/prebuilds.go
@@ -0,0 +1,44 @@
+package codersdk
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+)
+
+type PrebuildsSettings struct {
+ ReconciliationPaused bool `json:"reconciliation_paused"`
+}
+
+// GetPrebuildsSettings retrieves the prebuilds settings, which currently just describes whether all
+// prebuild reconciliation is paused.
+func (c *Client) GetPrebuildsSettings(ctx context.Context) (PrebuildsSettings, error) {
+ res, err := c.Request(ctx, http.MethodGet, "/api/v2/prebuilds/settings", nil)
+ if err != nil {
+ return PrebuildsSettings{}, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return PrebuildsSettings{}, ReadBodyAsError(res)
+ }
+ var settings PrebuildsSettings
+ return settings, json.NewDecoder(res.Body).Decode(&settings)
+}
+
+// PutPrebuildsSettings modifies the prebuilds settings, which currently just controls whether all
+// prebuild reconciliation is paused.
+func (c *Client) PutPrebuildsSettings(ctx context.Context, settings PrebuildsSettings) error {
+ res, err := c.Request(ctx, http.MethodPut, "/api/v2/prebuilds/settings", settings)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode == http.StatusNotModified {
+ return nil
+ }
+ if res.StatusCode != http.StatusOK {
+ return ReadBodyAsError(res)
+ }
+ return nil
+}
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index 01b3d8e61d595..d4c245c533692 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -25,6 +25,7 @@ We track the following resources:
| OAuth2ProviderAppSecret
|
Field | Tracked |
| app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
|
| Organization
| Field | Tracked |
| created_at | false |
deleted | true |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
|
| OrganizationSyncSettings
| Field | Tracked |
| assign_default | true |
field | true |
mapping | true |
|
+| PrebuildsSettings
| Field | Tracked |
| id | false |
reconciliation_paused | true |
|
| RoleSyncSettings
| Field | Tracked |
| field | true |
mapping | true |
|
| Template
write, delete | Field | Tracked |
| active_version_id | true |
activity_bump | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
autostart_block_days_of_week | true |
autostop_requirement_days_of_week | true |
autostop_requirement_weeks | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_name | false |
created_by_username | false |
default_ttl | true |
deleted | false |
deprecated | true |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
max_port_sharing_level | true |
name | true |
organization_display_name | false |
organization_icon | false |
organization_id | false |
organization_name | false |
provisioner | true |
require_active_version | true |
time_til_dormant | true |
time_til_dormant_autodelete | true |
updated_at | false |
use_classic_parameter_flow | true |
user_acl | true |
|
| TemplateVersion
create, write | Field | Tracked |
| archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_name | false |
created_by_username | false |
external_auth_providers | false |
has_ai_task | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
source_example_id | false |
template_id | true |
updated_at | false |
|
diff --git a/docs/manifest.json b/docs/manifest.json
index a139889baf68d..1b0db8c429e87 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1395,6 +1395,21 @@
"description": "Forward ports from a workspace to the local machine. For reverse port forwarding, use \"coder ssh -R\".",
"path": "reference/cli/port-forward.md"
},
+ {
+ "title": "prebuilds",
+ "description": "Manage Coder prebuilds",
+ "path": "reference/cli/prebuilds.md"
+ },
+ {
+ "title": "prebuilds pause",
+ "description": "Pause prebuilds",
+ "path": "reference/cli/prebuilds_pause.md"
+ },
+ {
+ "title": "prebuilds resume",
+ "description": "Resume prebuilds",
+ "path": "reference/cli/prebuilds_resume.md"
+ },
{
"title": "provisioner",
"description": "View and manage provisioner daemons and jobs",
diff --git a/docs/reference/api/prebuilds.md b/docs/reference/api/prebuilds.md
new file mode 100644
index 0000000000000..117e06d8c6317
--- /dev/null
+++ b/docs/reference/api/prebuilds.md
@@ -0,0 +1,79 @@
+# Prebuilds
+
+## Get prebuilds settings
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/api/v2/prebuilds/settings \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /prebuilds/settings`
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "reconciliation_paused": true
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------|
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.PrebuildsSettings](schemas.md#codersdkprebuildssettings) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Update prebuilds settings
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X PUT http://coder-server:8080/api/v2/prebuilds/settings \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`PUT /prebuilds/settings`
+
+> Body parameter
+
+```json
+{
+ "reconciliation_paused": true
+}
+```
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+|--------|------|--------------------------------------------------------------------|----------|----------------------------|
+| `body` | body | [codersdk.PrebuildsSettings](schemas.md#codersdkprebuildssettings) | true | Prebuilds settings request |
+
+### Example responses
+
+> 200 Response
+
+```json
+{
+ "reconciliation_paused": true
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|-----------------------------------------------------------------|--------------|--------------------------------------------------------------------|
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.PrebuildsSettings](schemas.md#codersdkprebuildssettings) |
+| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 389c7ec9c39c8..50a6f47edbeca 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -4937,6 +4937,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `reconciliation_backoff_lookback` | integer | false | | Reconciliation backoff lookback determines the time window to look back when calculating the number of failed prebuilds, which influences the backoff strategy. |
| `reconciliation_interval` | integer | false | | Reconciliation interval defines how often the workspace prebuilds state should be reconciled. |
+## codersdk.PrebuildsSettings
+
+```json
+{
+ "reconciliation_paused": true
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|-------------------------|---------|----------|--------------|-------------|
+| `reconciliation_paused` | boolean | false | | |
+
## codersdk.Preset
```json
@@ -6012,6 +6026,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `convert_login` |
| `health_settings` |
| `notifications_settings` |
+| `prebuilds_settings` |
| `workspace_proxy` |
| `organization` |
| `oauth2_provider_app` |
diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md
index 6dae32c4c615c..1992e5d6e9ac3 100644
--- a/docs/reference/cli/index.md
+++ b/docs/reference/cli/index.md
@@ -65,6 +65,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [features
](./features.md) | List Enterprise features |
| [licenses
](./licenses.md) | Add, delete, and list licenses |
| [groups
](./groups.md) | Manage groups |
+| [prebuilds
](./prebuilds.md) | Manage Coder prebuilds |
| [provisioner
](./provisioner.md) | View and manage provisioner daemons and jobs |
## Options
diff --git a/docs/reference/cli/prebuilds.md b/docs/reference/cli/prebuilds.md
new file mode 100644
index 0000000000000..90ee77dc91c1a
--- /dev/null
+++ b/docs/reference/cli/prebuilds.md
@@ -0,0 +1,34 @@
+
+# prebuilds
+
+Manage Coder prebuilds
+
+Aliases:
+
+* prebuild
+
+## Usage
+
+```console
+coder prebuilds
+```
+
+## Description
+
+```console
+Administrators can use these commands to manage prebuilt workspace settings.
+ - Pause Coder prebuilt workspace reconciliation.:
+
+ $ coder prebuilds pause
+
+ - Resume Coder prebuilt workspace reconciliation if it has been paused.:
+
+ $ coder prebuilds resume
+```
+
+## Subcommands
+
+| Name | Purpose |
+|----------------------------------------------|------------------|
+| [pause
](./prebuilds_pause.md) | Pause prebuilds |
+| [resume
](./prebuilds_resume.md) | Resume prebuilds |
diff --git a/docs/reference/cli/prebuilds_pause.md b/docs/reference/cli/prebuilds_pause.md
new file mode 100644
index 0000000000000..3aa8cf883a16f
--- /dev/null
+++ b/docs/reference/cli/prebuilds_pause.md
@@ -0,0 +1,10 @@
+
+# prebuilds pause
+
+Pause prebuilds
+
+## Usage
+
+```console
+coder prebuilds pause
+```
diff --git a/docs/reference/cli/prebuilds_resume.md b/docs/reference/cli/prebuilds_resume.md
new file mode 100644
index 0000000000000..00e9dadc6c578
--- /dev/null
+++ b/docs/reference/cli/prebuilds_resume.md
@@ -0,0 +1,10 @@
+
+# prebuilds resume
+
+Resume prebuilds
+
+## Usage
+
+```console
+coder prebuilds resume
+```
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index 120b4ed684bdf..edf805acb3cd4 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -236,6 +236,10 @@ var auditableResourcesTypes = map[any]map[string]Action{
"id": ActionIgnore,
"notifier_paused": ActionTrack,
},
+ &database.PrebuildsSettings{}: {
+ "id": ActionIgnore,
+ "reconciliation_paused": ActionTrack,
+ },
// TODO: track an ID here when the below ticket is completed:
// https://github.com/coder/coder/pull/6012
&database.License{}: {
diff --git a/enterprise/cli/prebuilds.go b/enterprise/cli/prebuilds.go
new file mode 100644
index 0000000000000..a75b14dd7023f
--- /dev/null
+++ b/enterprise/cli/prebuilds.go
@@ -0,0 +1,86 @@
+package cli
+
+import (
+ "fmt"
+
+ "golang.org/x/xerrors"
+
+ "github.com/coder/serpent"
+
+ "github.com/coder/coder/v2/cli"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+func (r *RootCmd) prebuilds() *serpent.Command {
+ cmd := &serpent.Command{
+ Use: "prebuilds",
+ Short: "Manage Coder prebuilds",
+ Long: "Administrators can use these commands to manage prebuilt workspace settings.\n" + cli.FormatExamples(
+ cli.Example{
+ Description: "Pause Coder prebuilt workspace reconciliation.",
+ Command: "coder prebuilds pause",
+ },
+ cli.Example{
+ Description: "Resume Coder prebuilt workspace reconciliation if it has been paused.",
+ Command: "coder prebuilds resume",
+ },
+ ),
+ Aliases: []string{"prebuild"},
+ Handler: func(inv *serpent.Invocation) error {
+ return inv.Command.HelpHandler(inv)
+ },
+ Children: []*serpent.Command{
+ r.pausePrebuilds(),
+ r.resumePrebuilds(),
+ },
+ }
+ return cmd
+}
+
+func (r *RootCmd) pausePrebuilds() *serpent.Command {
+ client := new(codersdk.Client)
+ cmd := &serpent.Command{
+ Use: "pause",
+ Short: "Pause prebuilds",
+ Middleware: serpent.Chain(
+ serpent.RequireNArgs(0),
+ r.InitClient(client),
+ ),
+ Handler: func(inv *serpent.Invocation) error {
+ err := client.PutPrebuildsSettings(inv.Context(), codersdk.PrebuildsSettings{
+ ReconciliationPaused: true,
+ })
+ if err != nil {
+ return xerrors.Errorf("unable to pause prebuilds: %w", err)
+ }
+
+ _, _ = fmt.Fprintln(inv.Stderr, "Prebuilds are now paused.")
+ return nil
+ },
+ }
+ return cmd
+}
+
+func (r *RootCmd) resumePrebuilds() *serpent.Command {
+ client := new(codersdk.Client)
+ cmd := &serpent.Command{
+ Use: "resume",
+ Short: "Resume prebuilds",
+ Middleware: serpent.Chain(
+ serpent.RequireNArgs(0),
+ r.InitClient(client),
+ ),
+ Handler: func(inv *serpent.Invocation) error {
+ err := client.PutPrebuildsSettings(inv.Context(), codersdk.PrebuildsSettings{
+ ReconciliationPaused: false,
+ })
+ if err != nil {
+ return xerrors.Errorf("unable to resume prebuilds: %w", err)
+ }
+
+ _, _ = fmt.Fprintln(inv.Stderr, "Prebuilds are now resumed.")
+ return nil
+ },
+ }
+ return cmd
+}
diff --git a/enterprise/cli/prebuilds_test.go b/enterprise/cli/prebuilds_test.go
new file mode 100644
index 0000000000000..b5960436edcfb
--- /dev/null
+++ b/enterprise/cli/prebuilds_test.go
@@ -0,0 +1,343 @@
+package cli_test
+
+import (
+ "bytes"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/cli/clitest"
+ "github.com/coder/coder/v2/coderd/coderdtest"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
+ "github.com/coder/coder/v2/enterprise/coderd/license"
+)
+
+func TestPrebuildsPause(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Success", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ inv, conf := newCLI(t, "prebuilds", "pause")
+ var buf bytes.Buffer
+ inv.Stderr = &buf
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.NoError(t, err)
+
+ // Verify the output message
+ assert.Contains(t, buf.String(), "Prebuilds are now paused.")
+
+ // Verify the settings were actually updated
+ //nolint:gocritic // Only owners can change deployment settings
+ settings, err := client.GetPrebuildsSettings(inv.Context())
+ require.NoError(t, err)
+ assert.True(t, settings.ReconciliationPaused)
+ })
+
+ t.Run("UnauthorizedUser", func(t *testing.T) {
+ t.Parallel()
+
+ adminClient, admin := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ // Create a regular user without admin privileges
+ client, _ := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
+
+ inv, conf := newCLI(t, "prebuilds", "pause")
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.Error(t, err)
+ var sdkError *codersdk.Error
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
+ })
+
+ t.Run("NoLicense", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ DontAddLicense: true,
+ })
+
+ inv, conf := newCLI(t, "prebuilds", "pause")
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.Error(t, err)
+ // Should fail without license
+ var sdkError *codersdk.Error
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
+ })
+
+ t.Run("AlreadyPaused", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ // First pause
+ inv1, conf := newCLI(t, "prebuilds", "pause")
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+ err := inv1.Run()
+ require.NoError(t, err)
+
+ // Try to pause again
+ inv2, conf2 := newCLI(t, "prebuilds", "pause")
+ clitest.SetupConfig(t, client, conf2)
+ err = inv2.Run()
+ require.NoError(t, err) // Should succeed even if already paused
+
+ // Verify still paused
+ //nolint:gocritic // Only owners can change deployment settings
+ settings, err := client.GetPrebuildsSettings(inv2.Context())
+ require.NoError(t, err)
+ assert.True(t, settings.ReconciliationPaused)
+ })
+}
+
+func TestPrebuildsResume(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Success", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ // First pause prebuilds
+ inv1, conf := newCLI(t, "prebuilds", "pause")
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+ err := inv1.Run()
+ require.NoError(t, err)
+
+ // Then resume
+ inv2, conf2 := newCLI(t, "prebuilds", "resume")
+ var buf bytes.Buffer
+ inv2.Stderr = &buf
+ clitest.SetupConfig(t, client, conf2)
+
+ err = inv2.Run()
+ require.NoError(t, err)
+
+ // Verify the output message
+ assert.Contains(t, buf.String(), "Prebuilds are now resumed.")
+
+ // Verify the settings were actually updated
+ //nolint:gocritic // Only owners can change deployment settings
+ settings, err := client.GetPrebuildsSettings(inv2.Context())
+ require.NoError(t, err)
+ assert.False(t, settings.ReconciliationPaused)
+ })
+
+ t.Run("ResumeWhenNotPaused", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ // Resume without first pausing
+ inv, conf := newCLI(t, "prebuilds", "resume")
+ var buf bytes.Buffer
+ inv.Stderr = &buf
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.NoError(t, err)
+
+ // Should succeed and show the message
+ assert.Contains(t, buf.String(), "Prebuilds are now resumed.")
+
+ // Verify still not paused
+ //nolint:gocritic // Only owners can change deployment settings
+ settings, err := client.GetPrebuildsSettings(inv.Context())
+ require.NoError(t, err)
+ assert.False(t, settings.ReconciliationPaused)
+ })
+
+ t.Run("UnauthorizedUser", func(t *testing.T) {
+ t.Parallel()
+
+ adminClient, admin := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ // Create a regular user without admin privileges
+ client, _ := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
+
+ inv, conf := newCLI(t, "prebuilds", "resume")
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.Error(t, err)
+ var sdkError *codersdk.Error
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
+ })
+
+ t.Run("NoLicense", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ DontAddLicense: true,
+ })
+
+ inv, conf := newCLI(t, "prebuilds", "resume")
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.Error(t, err)
+ // Should fail without license
+ var sdkError *codersdk.Error
+ require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
+ assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
+ })
+}
+
+func TestPrebuildsCommand(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Help", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ inv, conf := newCLI(t, "prebuilds", "--help")
+ var buf bytes.Buffer
+ inv.Stdout = &buf
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.NoError(t, err)
+
+ // Verify help output contains expected information
+ output := buf.String()
+ assert.Contains(t, output, "Manage Coder prebuilds")
+ assert.Contains(t, output, "pause")
+ assert.Contains(t, output, "resume")
+ assert.Contains(t, output, "Administrators can use these commands")
+ })
+
+ t.Run("NoSubcommand", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ inv, conf := newCLI(t, "prebuilds")
+ var buf bytes.Buffer
+ inv.Stdout = &buf
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+
+ err := inv.Run()
+ require.NoError(t, err)
+
+ // Should show help when no subcommand is provided
+ output := buf.String()
+ assert.Contains(t, output, "Manage Coder prebuilds")
+ assert.Contains(t, output, "pause")
+ assert.Contains(t, output, "resume")
+ })
+}
+
+func TestPrebuildsSettingsAPI(t *testing.T) {
+ t.Parallel()
+
+ t.Run("GetSettings", func(t *testing.T) {
+ t.Parallel()
+
+ client, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspacePrebuilds: 1,
+ },
+ },
+ })
+
+ // Get initial settings
+ //nolint:gocritic // Only owners can change deployment settings
+ settings, err := client.GetPrebuildsSettings(t.Context())
+ require.NoError(t, err)
+ assert.False(t, settings.ReconciliationPaused)
+
+ // Pause prebuilds
+ inv1, conf := newCLI(t, "prebuilds", "pause")
+ //nolint:gocritic // Only owners can change deployment settings
+ clitest.SetupConfig(t, client, conf)
+ err = inv1.Run()
+ require.NoError(t, err)
+
+ // Get settings again
+ settings, err = client.GetPrebuildsSettings(t.Context())
+ require.NoError(t, err)
+ assert.True(t, settings.ReconciliationPaused)
+
+ // Resume prebuilds
+ inv2, conf2 := newCLI(t, "prebuilds", "resume")
+ clitest.SetupConfig(t, client, conf2)
+ err = inv2.Run()
+ require.NoError(t, err)
+
+ // Get settings one more time
+ settings, err = client.GetPrebuildsSettings(t.Context())
+ require.NoError(t, err)
+ assert.False(t, settings.ReconciliationPaused)
+ })
+}
diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go
index 1af40ff1b2622..5b101fdbbb4b8 100644
--- a/enterprise/cli/root.go
+++ b/enterprise/cli/root.go
@@ -16,6 +16,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command {
r.features(),
r.licenses(),
r.groups(),
+ r.prebuilds(),
r.provisionerDaemons(),
r.provisionerd(),
}
diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden
index 1522921a3efdd..fc16bb29b9010 100644
--- a/enterprise/cli/testdata/coder_--help.golden
+++ b/enterprise/cli/testdata/coder_--help.golden
@@ -17,6 +17,7 @@ SUBCOMMANDS:
features List Enterprise features
groups Manage groups
licenses Add, delete, and list licenses
+ prebuilds Manage Coder prebuilds
provisioner View and manage provisioner daemons and jobs
server Start a Coder server
diff --git a/enterprise/cli/testdata/coder_prebuilds_--help.golden b/enterprise/cli/testdata/coder_prebuilds_--help.golden
new file mode 100644
index 0000000000000..505779ae8b7bd
--- /dev/null
+++ b/enterprise/cli/testdata/coder_prebuilds_--help.golden
@@ -0,0 +1,24 @@
+coder v0.0.0-devel
+
+USAGE:
+ coder prebuilds
+
+ Manage Coder prebuilds
+
+ Aliases: prebuild
+
+ Administrators can use these commands to manage prebuilt workspace settings.
+ - Pause Coder prebuilt workspace reconciliation.:
+
+ $ coder prebuilds pause
+
+ - Resume Coder prebuilt workspace reconciliation if it has been paused.:
+
+ $ coder prebuilds resume
+
+SUBCOMMANDS:
+ pause Pause prebuilds
+ resume Resume prebuilds
+
+———
+Run `coder --help` for a list of global options.
diff --git a/enterprise/cli/testdata/coder_prebuilds_pause_--help.golden b/enterprise/cli/testdata/coder_prebuilds_pause_--help.golden
new file mode 100644
index 0000000000000..9ce905c4a0178
--- /dev/null
+++ b/enterprise/cli/testdata/coder_prebuilds_pause_--help.golden
@@ -0,0 +1,9 @@
+coder v0.0.0-devel
+
+USAGE:
+ coder prebuilds pause
+
+ Pause prebuilds
+
+———
+Run `coder --help` for a list of global options.
diff --git a/enterprise/cli/testdata/coder_prebuilds_resume_--help.golden b/enterprise/cli/testdata/coder_prebuilds_resume_--help.golden
new file mode 100644
index 0000000000000..22671572bbbd9
--- /dev/null
+++ b/enterprise/cli/testdata/coder_prebuilds_resume_--help.golden
@@ -0,0 +1,9 @@
+coder v0.0.0-devel
+
+USAGE:
+ coder prebuilds resume
+
+ Resume prebuilds
+
+———
+Run `coder --help` for a list of global options.
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index 601700403f326..5f79608275f96 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -474,6 +474,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.userQuietHoursSchedule)
r.Put("/", api.putUserQuietHoursSchedule)
})
+ r.Route("/prebuilds", func(r chi.Router) {
+ r.Use(
+ apiKeyMiddleware,
+ api.RequireFeatureMW(codersdk.FeatureWorkspacePrebuilds),
+ )
+ r.Get("/settings", api.prebuildsSettings)
+ r.Put("/settings", api.putPrebuildsSettings)
+ })
// The /notifications base route is mounted by the AGPL router, so we can't group it here.
// Additionally, because we have a static route for /notifications/templates/system which conflicts
// with the below route, we need to register this route without any mounts or groups to make both work.
diff --git a/enterprise/coderd/prebuilds.go b/enterprise/coderd/prebuilds.go
new file mode 100644
index 0000000000000..837bc17ad0db9
--- /dev/null
+++ b/enterprise/coderd/prebuilds.go
@@ -0,0 +1,120 @@
+package coderd
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+
+ "github.com/coder/coder/v2/coderd/audit"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/prebuilds"
+)
+
+// @Summary Get prebuilds settings
+// @ID get-prebuilds-settings
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Prebuilds
+// @Success 200 {object} codersdk.PrebuildsSettings
+// @Router /prebuilds/settings [get]
+func (api *API) prebuildsSettings(rw http.ResponseWriter, r *http.Request) {
+ settingsJSON, err := api.Database.GetPrebuildsSettings(r.Context())
+ if err != nil {
+ httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to fetch current prebuilds settings.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ var settings codersdk.PrebuildsSettings
+ if len(settingsJSON) > 0 {
+ if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
+ httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to unmarshal prebuilds settings.",
+ Detail: err.Error(),
+ })
+ return
+ }
+ }
+
+ httpapi.Write(r.Context(), rw, http.StatusOK, settings)
+}
+
+// @Summary Update prebuilds settings
+// @ID update-prebuilds-settings
+// @Security CoderSessionToken
+// @Accept json
+// @Produce json
+// @Tags Prebuilds
+// @Param request body codersdk.PrebuildsSettings true "Prebuilds settings request"
+// @Success 200 {object} codersdk.PrebuildsSettings
+// @Success 304
+// @Router /prebuilds/settings [put]
+func (api *API) putPrebuildsSettings(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ var settings codersdk.PrebuildsSettings
+ if !httpapi.Read(ctx, rw, r, &settings) {
+ return
+ }
+
+ settingsJSON, err := json.Marshal(&settings)
+ if err != nil {
+ httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to marshal prebuilds settings.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ currentSettingsJSON, err := api.Database.GetPrebuildsSettings(ctx)
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to fetch current prebuilds settings.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) {
+ // See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1
+ httpapi.Write(ctx, rw, http.StatusNotModified, nil)
+ return
+ }
+
+ auditor := api.AGPL.Auditor.Load()
+ aReq, commitAudit := audit.InitRequest[database.PrebuildsSettings](rw, &audit.RequestParams{
+ Audit: *auditor,
+ Log: api.Logger,
+ Request: r,
+ Action: database.AuditActionWrite,
+ })
+ defer commitAudit()
+
+ aReq.New = database.PrebuildsSettings{
+ ID: uuid.New(),
+ ReconciliationPaused: settings.ReconciliationPaused,
+ }
+
+ err = prebuilds.SetPrebuildsReconciliationPaused(ctx, api.Database, settings.ReconciliationPaused)
+ if err != nil {
+ if rbac.IsUnauthorizedError(err) {
+ httpapi.Forbidden(rw)
+ return
+ }
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Failed to update prebuilds settings.",
+ Detail: err.Error(),
+ })
+
+ return
+ }
+
+ httpapi.Write(r.Context(), rw, http.StatusOK, settings)
+}
diff --git a/enterprise/coderd/prebuilds/metricscollector.go b/enterprise/coderd/prebuilds/metricscollector.go
index 4499849ffde0a..97b295dd19426 100644
--- a/enterprise/coderd/prebuilds/metricscollector.go
+++ b/enterprise/coderd/prebuilds/metricscollector.go
@@ -29,6 +29,7 @@ const (
MetricEligibleGauge = namespace + "eligible"
MetricPresetHardLimitedGauge = namespace + "preset_hard_limited"
MetricLastUpdatedGauge = namespace + "metrics_last_updated"
+ MetricReconciliationPausedGauge = namespace + "reconciliation_paused"
)
var (
@@ -95,6 +96,12 @@ var (
[]string{},
nil,
)
+ reconciliationPausedDesc = prometheus.NewDesc(
+ MetricReconciliationPausedGauge,
+ "Indicates whether prebuilds reconciliation is currently paused (1 = paused, 0 = not paused).",
+ []string{},
+ nil,
+ )
)
const (
@@ -114,6 +121,9 @@ type MetricsCollector struct {
isPresetHardLimited map[hardLimitedPresetKey]bool
isPresetHardLimitedMu sync.Mutex
+
+ reconciliationPaused bool
+ reconciliationPausedMu sync.RWMutex
}
var _ prometheus.Collector = new(MetricsCollector)
@@ -140,12 +150,22 @@ func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) {
descCh <- eligiblePrebuildsDesc
descCh <- presetHardLimitedDesc
descCh <- lastUpdateDesc
+ descCh <- reconciliationPausedDesc
}
// Collect uses the cached state to set configured metrics.
// The state is cached because this function can be called multiple times per second and retrieving the current state
// is an expensive operation.
func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) {
+ mc.reconciliationPausedMu.RLock()
+ var pausedValue float64
+ if mc.reconciliationPaused {
+ pausedValue = 1
+ }
+ mc.reconciliationPausedMu.RUnlock()
+
+ metricsCh <- prometheus.MustNewConstMetric(reconciliationPausedDesc, prometheus.GaugeValue, pausedValue)
+
currentState := mc.latestState.Load() // Grab a copy; it's ok if it goes stale during the course of this func.
if currentState == nil {
mc.logger.Warn(context.Background(), "failed to set prebuilds metrics; state not set")
@@ -286,3 +306,10 @@ func (mc *MetricsCollector) registerHardLimitedPresets(isPresetHardLimited map[h
mc.isPresetHardLimited = isPresetHardLimited
}
+
+func (mc *MetricsCollector) setReconciliationPaused(paused bool) {
+ mc.reconciliationPausedMu.Lock()
+ defer mc.reconciliationPausedMu.Unlock()
+
+ mc.reconciliationPaused = paused
+}
diff --git a/enterprise/coderd/prebuilds/metricscollector_test.go b/enterprise/coderd/prebuilds/metricscollector_test.go
index 057f310fa2bc2..96c3d071ac48a 100644
--- a/enterprise/coderd/prebuilds/metricscollector_test.go
+++ b/enterprise/coderd/prebuilds/metricscollector_test.go
@@ -476,3 +476,97 @@ func findAllMetricSeries(metricsFamilies []*prometheus_client.MetricFamily, labe
}
return series
}
+
+func TestMetricsCollector_ReconciliationPausedMetric(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("this test requires postgres")
+ }
+
+ t.Run("reconciliation_not_paused", func(t *testing.T) {
+ t.Parallel()
+
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+ db, pubsub := dbtestutil.NewDB(t)
+ cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
+ registry := prometheus.NewPedanticRegistry()
+ reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), registry, newNoopEnqueuer())
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Ensure no pause setting is set (default state)
+ err := db.UpsertPrebuildsSettings(ctx, `{}`)
+ require.NoError(t, err)
+
+ // Run reconciliation to update the metric
+ err = reconciler.ReconcileAll(ctx)
+ require.NoError(t, err)
+
+ // Check that the metric shows reconciliation is not paused
+ metricsFamilies, err := registry.Gather()
+ require.NoError(t, err)
+
+ metric := findMetric(metricsFamilies, prebuilds.MetricReconciliationPausedGauge, map[string]string{})
+ require.NotNil(t, metric, "reconciliation paused metric should exist")
+ require.NotNil(t, metric.GetGauge())
+ require.Equal(t, 0.0, metric.GetGauge().GetValue(), "reconciliation should not be paused")
+ })
+
+ t.Run("reconciliation_paused", func(t *testing.T) {
+ t.Parallel()
+
+ // Create isolated collector and registry for this test
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+ db, pubsub := dbtestutil.NewDB(t)
+ cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
+ registry := prometheus.NewPedanticRegistry()
+ reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), registry, newNoopEnqueuer())
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Set reconciliation to paused
+ err := prebuilds.SetPrebuildsReconciliationPaused(ctx, db, true)
+ require.NoError(t, err)
+
+ // Run reconciliation to update the metric
+ err = reconciler.ReconcileAll(ctx)
+ require.NoError(t, err)
+
+ // Check that the metric shows reconciliation is paused
+ metricsFamilies, err := registry.Gather()
+ require.NoError(t, err)
+
+ metric := findMetric(metricsFamilies, prebuilds.MetricReconciliationPausedGauge, map[string]string{})
+ require.NotNil(t, metric, "reconciliation paused metric should exist")
+ require.NotNil(t, metric.GetGauge())
+ require.Equal(t, 1.0, metric.GetGauge().GetValue(), "reconciliation should be paused")
+ })
+
+ t.Run("reconciliation_resumed", func(t *testing.T) {
+ t.Parallel()
+
+ // Create isolated collector and registry for this test
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
+ db, pubsub := dbtestutil.NewDB(t)
+ cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
+ registry := prometheus.NewPedanticRegistry()
+ reconciler := prebuilds.NewStoreReconciler(db, pubsub, cache, codersdk.PrebuildsConfig{}, logger, quartz.NewMock(t), registry, newNoopEnqueuer())
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Set reconciliation back to not paused
+ err := prebuilds.SetPrebuildsReconciliationPaused(ctx, db, false)
+ require.NoError(t, err)
+
+ // Run reconciliation to update the metric
+ err = reconciler.ReconcileAll(ctx)
+ require.NoError(t, err)
+
+ // Check that the metric shows reconciliation is not paused
+ metricsFamilies, err := registry.Gather()
+ require.NoError(t, err)
+
+ metric := findMetric(metricsFamilies, prebuilds.MetricReconciliationPausedGauge, map[string]string{})
+ require.NotNil(t, metric, "reconciliation paused metric should exist")
+ require.NotNil(t, metric.GetGauge())
+ require.Equal(t, 0.0, metric.GetGauge().GetValue(), "reconciliation should not be paused")
+ })
+}
diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go
index 343a639845f44..44e0e82c8881a 100644
--- a/enterprise/coderd/prebuilds/reconcile.go
+++ b/enterprise/coderd/prebuilds/reconcile.go
@@ -3,6 +3,7 @@ package prebuilds
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"fmt"
"math"
@@ -14,7 +15,6 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/prometheus/client_golang/prometheus"
- "github.com/coder/coder/v2/coderd/files"
"github.com/coder/quartz"
"github.com/coder/coder/v2/coderd/audit"
@@ -22,6 +22,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/database/pubsub"
+ "github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac"
@@ -256,6 +257,28 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error {
logger.Debug(ctx, "starting reconciliation")
err := c.WithReconciliationLock(ctx, logger, func(ctx context.Context, _ database.Store) error {
+ // Check if prebuilds reconciliation is paused
+ settingsJSON, err := c.store.GetPrebuildsSettings(ctx)
+ if err != nil {
+ return xerrors.Errorf("get prebuilds settings: %w", err)
+ }
+
+ var settings codersdk.PrebuildsSettings
+ if len(settingsJSON) > 0 {
+ if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
+ return xerrors.Errorf("unmarshal prebuilds settings: %w", err)
+ }
+ }
+
+ if c.metrics != nil {
+ c.metrics.setReconciliationPaused(settings.ReconciliationPaused)
+ }
+
+ if settings.ReconciliationPaused {
+ logger.Info(ctx, "prebuilds reconciliation is paused, skipping reconciliation")
+ return nil
+ }
+
snapshot, err := c.SnapshotState(ctx, c.store)
if err != nil {
return xerrors.Errorf("determine current snapshot: %w", err)
@@ -884,3 +907,18 @@ func (c *StoreReconciler) trackResourceReplacement(ctx context.Context, workspac
return notifErr
}
+
+type Settings struct {
+ ReconciliationPaused bool `json:"reconciliation_paused"`
+}
+
+func SetPrebuildsReconciliationPaused(ctx context.Context, db database.Store, paused bool) error {
+ settings := Settings{
+ ReconciliationPaused: paused,
+ }
+ settingsJSON, err := json.Marshal(settings)
+ if err != nil {
+ return xerrors.Errorf("marshal settings: %w", err)
+ }
+ return db.UpsertPrebuildsSettings(ctx, string(settingsJSON))
+}
diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go
index 44ecf168a03ca..fce5269214ed1 100644
--- a/enterprise/coderd/prebuilds/reconcile_test.go
+++ b/enterprise/coderd/prebuilds/reconcile_test.go
@@ -2143,3 +2143,80 @@ func mustParseTime(t *testing.T, layout, value string) time.Time {
require.NoError(t, err)
return parsedTime
}
+
+func TestReconciliationRespectsPauseSetting(t *testing.T) {
+ t.Parallel()
+
+ if !dbtestutil.WillUsePostgres() {
+ t.Skip("This test requires postgres")
+ }
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ clock := quartz.NewMock(t)
+ db, ps := dbtestutil.NewDB(t)
+ cfg := codersdk.PrebuildsConfig{
+ ReconciliationInterval: serpent.Duration(testutil.WaitLong),
+ }
+ logger := testutil.Logger(t)
+ cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
+ reconciler := prebuilds.NewStoreReconciler(db, ps, cache, cfg, logger, clock, prometheus.NewRegistry(), newNoopEnqueuer())
+
+ // Setup a template with a preset that should create prebuilds
+ org := dbgen.Organization(t, db, database.Organization{})
+ user := dbgen.User(t, db, database.User{})
+ template := dbgen.Template(t, db, database.Template{
+ CreatedBy: user.ID,
+ OrganizationID: org.ID,
+ })
+ templateVersionID := setupTestDBTemplateVersion(ctx, t, clock, db, ps, org.ID, user.ID, template.ID)
+ _ = setupTestDBPreset(t, db, templateVersionID, 2, "test")
+
+ // Initially, reconciliation should create prebuilds
+ err := reconciler.ReconcileAll(ctx)
+ require.NoError(t, err)
+
+ // Verify that prebuilds were created
+ workspaces, err := db.GetWorkspacesByTemplateID(ctx, template.ID)
+ require.NoError(t, err)
+ require.Len(t, workspaces, 2, "should have created 2 prebuilds")
+
+ // Now pause prebuilds reconciliation
+ err = prebuilds.SetPrebuildsReconciliationPaused(ctx, db, true)
+ require.NoError(t, err)
+
+ // Delete the existing prebuilds to simulate a scenario where reconciliation would normally recreate them
+ for _, workspace := range workspaces {
+ err = db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{
+ ID: workspace.ID,
+ Deleted: true,
+ })
+ require.NoError(t, err)
+ }
+
+ // Verify prebuilds are deleted
+ workspaces, err = db.GetWorkspacesByTemplateID(ctx, template.ID)
+ require.NoError(t, err)
+ require.Len(t, workspaces, 0, "prebuilds should be deleted")
+
+ // Run reconciliation again - it should be paused and not recreate prebuilds
+ err = reconciler.ReconcileAll(ctx)
+ require.NoError(t, err)
+
+ // Verify that no new prebuilds were created because reconciliation is paused
+ workspaces, err = db.GetWorkspacesByTemplateID(ctx, template.ID)
+ require.NoError(t, err)
+ require.Len(t, workspaces, 0, "should not create prebuilds when reconciliation is paused")
+
+ // Resume prebuilds reconciliation
+ err = prebuilds.SetPrebuildsReconciliationPaused(ctx, db, false)
+ require.NoError(t, err)
+
+ // Run reconciliation again - it should now recreate the prebuilds
+ err = reconciler.ReconcileAll(ctx)
+ require.NoError(t, err)
+
+ // Verify that prebuilds were recreated
+ workspaces, err = db.GetWorkspacesByTemplateID(ctx, template.ID)
+ require.NoError(t, err)
+ require.Len(t, workspaces, 2, "should have recreated 2 prebuilds after resuming")
+}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 6552c0f0d56d3..7699043dabf82 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1772,6 +1772,11 @@ export interface PrebuildsConfig {
readonly failure_hard_limit: number;
}
+// From codersdk/prebuilds.go
+export interface PrebuildsSettings {
+ readonly reconciliation_paused: boolean;
+}
+
// From codersdk/presets.go
export interface Preset {
readonly ID: string;
@@ -2277,6 +2282,7 @@ export type ResourceType =
| "oauth2_provider_app_secret"
| "organization"
| "organization_member"
+ | "prebuilds_settings"
| "template"
| "template_version"
| "user"
@@ -2303,6 +2309,7 @@ export const ResourceTypes: ResourceType[] = [
"oauth2_provider_app_secret",
"organization",
"organization_member",
+ "prebuilds_settings",
"template",
"template_version",
"user",