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
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| | Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| | OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| +| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| 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",