feat: add prebuilds configuration & bootstrapping (#17527)

Closes https://github.com/coder/internal/issues/508

---------

Signed-off-by: Danny Kopping <dannykopping@gmail.com>
Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
Danny Kopping
2025-04-25 11:07:15 +02:00
committed by GitHub
parent e562e3c882
commit 08ad910171
14 changed files with 328 additions and 46 deletions

View File

@@ -688,3 +688,16 @@ notifications:
# How often to query the database for queued notifications.
# (default: 15s, type: duration)
fetchInterval: 15s
# Configure how workspace prebuilds behave.
workspace_prebuilds:
# How often to reconcile workspace prebuilds state.
# (default: 15s, type: duration)
reconciliation_interval: 15s
# Interval to increase reconciliation backoff by when prebuilds fail, after which
# a retry attempt is made.
# (default: 15s, type: duration)
reconciliation_backoff_interval: 15s
# Interval to look back to determine number of failed prebuilds, which influences
# backoff.
# (default: 1h0m0s, type: duration)
reconciliation_backoff_lookback_period: 1h0m0s

27
coderd/apidoc/docs.go generated
View File

@@ -11926,6 +11926,9 @@ const docTemplate = `{
"workspace_hostname_suffix": {
"type": "string"
},
"workspace_prebuilds": {
"$ref": "#/definitions/codersdk.PrebuildsConfig"
},
"write_config": {
"type": "boolean"
}
@@ -12005,7 +12008,8 @@ const docTemplate = `{
"notifications",
"workspace-usage",
"web-push",
"dynamic-parameters"
"dynamic-parameters",
"workspace-prebuilds"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
@@ -12013,6 +12017,7 @@ const docTemplate = `{
"ExperimentExample": "This isn't used for anything.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentWebPush": "Enables web push notifications through the browser.",
"ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.",
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
},
"x-enum-varnames": [
@@ -12021,7 +12026,8 @@ const docTemplate = `{
"ExperimentNotifications",
"ExperimentWorkspaceUsage",
"ExperimentWebPush",
"ExperimentDynamicParameters"
"ExperimentDynamicParameters",
"ExperimentWorkspacePrebuilds"
]
},
"codersdk.ExternalAuth": {
@@ -13654,6 +13660,23 @@ const docTemplate = `{
}
}
},
"codersdk.PrebuildsConfig": {
"type": "object",
"properties": {
"reconciliation_backoff_interval": {
"description": "ReconciliationBackoffInterval specifies the amount of time to increase the backoff interval\nwhen errors occur during reconciliation.",
"type": "integer"
},
"reconciliation_backoff_lookback": {
"description": "ReconciliationBackoffLookback determines the time window to look back when calculating\nthe number of failed prebuilds, which influences the backoff strategy.",
"type": "integer"
},
"reconciliation_interval": {
"description": "ReconciliationInterval defines how often the workspace prebuilds state should be reconciled.",
"type": "integer"
}
}
},
"codersdk.Preset": {
"type": "object",
"properties": {

View File

@@ -10684,6 +10684,9 @@
"workspace_hostname_suffix": {
"type": "string"
},
"workspace_prebuilds": {
"$ref": "#/definitions/codersdk.PrebuildsConfig"
},
"write_config": {
"type": "boolean"
}
@@ -10759,7 +10762,8 @@
"notifications",
"workspace-usage",
"web-push",
"dynamic-parameters"
"dynamic-parameters",
"workspace-prebuilds"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
@@ -10767,6 +10771,7 @@
"ExperimentExample": "This isn't used for anything.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentWebPush": "Enables web push notifications through the browser.",
"ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.",
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
},
"x-enum-varnames": [
@@ -10775,7 +10780,8 @@
"ExperimentNotifications",
"ExperimentWorkspaceUsage",
"ExperimentWebPush",
"ExperimentDynamicParameters"
"ExperimentDynamicParameters",
"ExperimentWorkspacePrebuilds"
]
},
"codersdk.ExternalAuth": {
@@ -12346,6 +12352,23 @@
}
}
},
"codersdk.PrebuildsConfig": {
"type": "object",
"properties": {
"reconciliation_backoff_interval": {
"description": "ReconciliationBackoffInterval specifies the amount of time to increase the backoff interval\nwhen errors occur during reconciliation.",
"type": "integer"
},
"reconciliation_backoff_lookback": {
"description": "ReconciliationBackoffLookback determines the time window to look back when calculating\nthe number of failed prebuilds, which influences the backoff strategy.",
"type": "integer"
},
"reconciliation_interval": {
"description": "ReconciliationInterval defines how often the workspace prebuilds state should be reconciled.",
"type": "integer"
}
}
},
"codersdk.Preset": {
"type": "object",
"properties": {

View File

@@ -597,6 +597,7 @@ func New(options *Options) *API {
api.AppearanceFetcher.Store(&f)
api.PortSharer.Store(&portsharing.DefaultPortSharer)
api.PrebuildsClaimer.Store(&prebuilds.DefaultClaimer)
api.PrebuildsReconciler.Store(&prebuilds.DefaultReconciler)
buildInfo := codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
@@ -1568,10 +1569,11 @@ type API struct {
DERPMapper atomic.Pointer[func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap]
// AccessControlStore is a pointer to an atomic pointer since it is
// passed to dbauthz.
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
PortSharer atomic.Pointer[portsharing.PortSharer]
FileCache files.Cache
PrebuildsClaimer atomic.Pointer[prebuilds.Claimer]
AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore]
PortSharer atomic.Pointer[portsharing.PortSharer]
FileCache files.Cache
PrebuildsClaimer atomic.Pointer[prebuilds.Claimer]
PrebuildsReconciler atomic.Pointer[prebuilds.ReconciliationOrchestrator]
UpdatesProvider tailnet.WorkspaceUpdatesProvider
@@ -1659,6 +1661,13 @@ func (api *API) Close() error {
_ = api.AppSigningKeyCache.Close()
_ = api.AppEncryptionKeyCache.Close()
_ = api.UpdatesProvider.Close()
if current := api.PrebuildsReconciler.Load(); current != nil {
ctx, giveUp := context.WithTimeoutCause(context.Background(), time.Second*30, xerrors.New("gave up waiting for reconciler to stop before shutdown"))
defer giveUp()
(*current).Stop(ctx, nil)
}
return nil
}

View File

@@ -14,10 +14,10 @@ var ErrNoClaimablePrebuiltWorkspaces = xerrors.New("no claimable prebuilt worksp
type ReconciliationOrchestrator interface {
Reconciler
// RunLoop starts a continuous reconciliation loop that periodically calls ReconcileAll
// Run starts a continuous reconciliation loop that periodically calls ReconcileAll
// to ensure all prebuilds are in their desired states. The loop runs until the context
// is canceled or Stop is called.
RunLoop(ctx context.Context)
Run(ctx context.Context)
// Stop gracefully shuts down the orchestrator with the given cause.
// The cause is used for logging and error reporting.

View File

@@ -10,41 +10,28 @@ import (
type NoopReconciler struct{}
func NewNoopReconciler() *NoopReconciler {
return &NoopReconciler{}
}
func (NoopReconciler) RunLoop(context.Context) {}
func (NoopReconciler) Stop(context.Context, error) {}
func (NoopReconciler) ReconcileAll(context.Context) error {
return nil
}
func (NoopReconciler) Run(context.Context) {}
func (NoopReconciler) Stop(context.Context, error) {}
func (NoopReconciler) ReconcileAll(context.Context) error { return nil }
func (NoopReconciler) SnapshotState(context.Context, database.Store) (*GlobalSnapshot, error) {
return &GlobalSnapshot{}, nil
}
func (NoopReconciler) ReconcilePreset(context.Context, PresetSnapshot) error {
return nil
}
func (NoopReconciler) ReconcilePreset(context.Context, PresetSnapshot) error { return nil }
func (NoopReconciler) CalculateActions(context.Context, PresetSnapshot) (*ReconciliationActions, error) {
return &ReconciliationActions{}, nil
}
var _ ReconciliationOrchestrator = NoopReconciler{}
var DefaultReconciler ReconciliationOrchestrator = NoopReconciler{}
type AGPLPrebuildClaimer struct{}
type NoopClaimer struct{}
func (AGPLPrebuildClaimer) Claim(context.Context, uuid.UUID, string, uuid.UUID) (*uuid.UUID, error) {
func (NoopClaimer) Claim(context.Context, uuid.UUID, string, uuid.UUID) (*uuid.UUID, error) {
// Not entitled to claim prebuilds in AGPL version.
return nil, ErrNoClaimablePrebuiltWorkspaces
}
func (AGPLPrebuildClaimer) Initiator() uuid.UUID {
func (NoopClaimer) Initiator() uuid.UUID {
return uuid.Nil
}
var DefaultClaimer Claimer = AGPLPrebuildClaimer{}
var DefaultClaimer Claimer = NoopClaimer{}

View File

@@ -81,6 +81,7 @@ const (
FeatureControlSharedPorts FeatureName = "control_shared_ports"
FeatureCustomRoles FeatureName = "custom_roles"
FeatureMultipleOrganizations FeatureName = "multiple_organizations"
FeatureWorkspacePrebuilds FeatureName = "workspace_prebuilds"
)
// FeatureNames must be kept in-sync with the Feature enum above.
@@ -103,6 +104,7 @@ var FeatureNames = []FeatureName{
FeatureControlSharedPorts,
FeatureCustomRoles,
FeatureMultipleOrganizations,
FeatureWorkspacePrebuilds,
}
// Humanize returns the feature name in a human-readable format.
@@ -132,6 +134,7 @@ func (n FeatureName) AlwaysEnable() bool {
FeatureHighAvailability: true,
FeatureCustomRoles: true,
FeatureMultipleOrganizations: true,
FeatureWorkspacePrebuilds: true,
}[n]
}
@@ -394,6 +397,7 @@ type DeploymentValues struct {
Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"`
AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,omitempty" typescript:",notnull"`
WorkspaceHostnameSuffix serpent.String `json:"workspace_hostname_suffix,omitempty" typescript:",notnull"`
Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -1034,6 +1038,11 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Parent: &deploymentGroupNotifications,
YAML: "webhook",
}
deploymentGroupPrebuilds = serpent.Group{
Name: "Workspace Prebuilds",
YAML: "workspace_prebuilds",
Description: "Configure how workspace prebuilds behave.",
}
deploymentGroupInbox = serpent.Group{
Name: "Inbox",
Parent: &deploymentGroupNotifications,
@@ -3029,7 +3038,44 @@ Write out the current server config as YAML to stdout.`,
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
Hidden: true, // Hidden because most operators should not need to modify this.
},
// Push notifications.
// Workspace Prebuilds Options
{
Name: "Reconciliation Interval",
Description: "How often to reconcile workspace prebuilds state.",
Flag: "workspace-prebuilds-reconciliation-interval",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL",
Value: &c.Prebuilds.ReconciliationInterval,
Default: (time.Second * 15).String(),
Group: &deploymentGroupPrebuilds,
YAML: "reconciliation_interval",
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
Hidden: ExperimentsSafe.Enabled(ExperimentWorkspacePrebuilds), // Hide setting while this feature is experimental.
},
{
Name: "Reconciliation Backoff Interval",
Description: "Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made.",
Flag: "workspace-prebuilds-reconciliation-backoff-interval",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_INTERVAL",
Value: &c.Prebuilds.ReconciliationBackoffInterval,
Default: (time.Second * 15).String(),
Group: &deploymentGroupPrebuilds,
YAML: "reconciliation_backoff_interval",
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
Hidden: true,
},
{
Name: "Reconciliation Backoff Lookback Period",
Description: "Interval to look back to determine number of failed prebuilds, which influences backoff.",
Flag: "workspace-prebuilds-reconciliation-backoff-lookback-period",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_LOOKBACK_PERIOD",
Value: &c.Prebuilds.ReconciliationBackoffLookback,
Default: (time.Hour).String(), // TODO: use https://pkg.go.dev/github.com/jackc/pgtype@v1.12.0#Interval
Group: &deploymentGroupPrebuilds,
YAML: "reconciliation_backoff_lookback_period",
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
Hidden: true,
},
}
return opts
@@ -3256,13 +3302,16 @@ const (
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser.
ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace.
ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature.
)
// ExperimentsSafe should include all experiments that are safe for
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
var ExperimentsSafe = Experiments{}
var ExperimentsSafe = Experiments{
ExperimentWorkspacePrebuilds,
}
// Experiments is a list of experiments.
// Multiple experiments may be enabled at the same time.

View File

@@ -519,6 +519,11 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"wgtunnel_host": "string",
"wildcard_access_url": "string",
"workspace_hostname_suffix": "string",
"workspace_prebuilds": {
"reconciliation_backoff_interval": 0,
"reconciliation_backoff_lookback": 0,
"reconciliation_interval": 0
},
"write_config": true
},
"options": [

View File

@@ -2170,6 +2170,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"wgtunnel_host": "string",
"wildcard_access_url": "string",
"workspace_hostname_suffix": "string",
"workspace_prebuilds": {
"reconciliation_backoff_interval": 0,
"reconciliation_backoff_lookback": 0,
"reconciliation_interval": 0
},
"write_config": true
},
"options": [
@@ -2650,6 +2655,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"wgtunnel_host": "string",
"wildcard_access_url": "string",
"workspace_hostname_suffix": "string",
"workspace_prebuilds": {
"reconciliation_backoff_interval": 0,
"reconciliation_backoff_lookback": 0,
"reconciliation_interval": 0
},
"write_config": true
}
```
@@ -2719,6 +2729,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `wgtunnel_host` | string | false | | |
| `wildcard_access_url` | string | false | | |
| `workspace_hostname_suffix` | string | false | | |
| `workspace_prebuilds` | [codersdk.PrebuildsConfig](#codersdkprebuildsconfig) | false | | |
| `write_config` | boolean | false | | |
## codersdk.DisplayApp
@@ -2817,6 +2828,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `workspace-usage` |
| `web-push` |
| `dynamic-parameters` |
| `workspace-prebuilds` |
## codersdk.ExternalAuth
@@ -4659,6 +4671,24 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `address` | [serpent.HostPort](#serpenthostport) | false | | |
| `enable` | boolean | false | | |
## codersdk.PrebuildsConfig
```json
{
"reconciliation_backoff_interval": 0,
"reconciliation_backoff_lookback": 0,
"reconciliation_interval": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-----------------------------------|---------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `reconciliation_backoff_interval` | integer | false | | Reconciliation backoff interval specifies the amount of time to increase the backoff interval when errors occur during reconciliation. |
| `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.Preset
```json

View File

@@ -12,12 +12,15 @@ import (
"sync"
"time"
"github.com/coder/quartz"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/idpsync"
agplportsharing "github.com/coder/coder/v2/coderd/portsharing"
agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/enterprise/coderd/enidpsync"
"github.com/coder/coder/v2/enterprise/coderd/portsharing"
@@ -43,6 +46,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/dbauthz"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
"github.com/coder/coder/v2/enterprise/coderd/schedule"
"github.com/coder/coder/v2/enterprise/dbcrypt"
@@ -658,6 +662,7 @@ func (api *API) Close() error {
if api.Options.CheckInactiveUsersCancelFunc != nil {
api.Options.CheckInactiveUsersCancelFunc()
}
return api.AGPL.Close()
}
@@ -860,6 +865,20 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.PortSharer.Store(&ps)
}
if initial, changed, enabled := featureChanged(codersdk.FeatureWorkspacePrebuilds); shouldUpdate(initial, changed, enabled) {
reconciler, claimer := api.setupPrebuilds(enabled)
if current := api.AGPL.PrebuildsReconciler.Load(); current != nil {
stopCtx, giveUp := context.WithTimeoutCause(context.Background(), time.Second*30, xerrors.New("gave up waiting for reconciler to stop"))
defer giveUp()
(*current).Stop(stopCtx, xerrors.New("entitlements change"))
}
api.AGPL.PrebuildsReconciler.Store(&reconciler)
go reconciler.Run(context.Background())
api.AGPL.PrebuildsClaimer.Store(&claimer)
}
// External token encryption is soft-enforced
featureExternalTokenEncryption := reloadedEntitlements.Features[codersdk.FeatureExternalTokenEncryption]
featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0
@@ -1128,3 +1147,24 @@ func (api *API) runEntitlementsLoop(ctx context.Context) {
func (api *API) Authorize(r *http.Request, action policy.Action, object rbac.Objecter) bool {
return api.AGPL.HTTPAuth.Authorize(r, action, object)
}
// nolint:revive // featureEnabled is a legit control flag.
func (api *API) setupPrebuilds(featureEnabled bool) (agplprebuilds.ReconciliationOrchestrator, agplprebuilds.Claimer) {
experimentEnabled := api.AGPL.Experiments.Enabled(codersdk.ExperimentWorkspacePrebuilds)
if !experimentEnabled || !featureEnabled {
levelFn := api.Logger.Debug
// If the experiment is enabled but the license does not entitle the feature, operators should be warned.
if !featureEnabled {
levelFn = api.Logger.Warn
}
levelFn(context.Background(), "prebuilds not enabled; ensure you have a premium license and the 'workspace-prebuilds' experiment set",
slog.F("experiment_enabled", experimentEnabled), slog.F("feature_enabled", featureEnabled))
return agplprebuilds.DefaultReconciler, agplprebuilds.DefaultClaimer
}
reconciler := prebuilds.NewStoreReconciler(api.Database, api.Pubsub, api.DeploymentValues.Prebuilds,
api.Logger.Named("prebuilds"), quartz.NewReal())
return reconciler, prebuilds.EnterpriseClaimer{}
}

View File

@@ -28,10 +28,15 @@ import (
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/httpapi"
agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/tailnet/tailnettest"
"github.com/coder/retry"
"github.com/coder/serpent"
agplaudit "github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
@@ -50,8 +55,6 @@ import (
"github.com/coder/coder/v2/enterprise/dbcrypt"
"github.com/coder/coder/v2/enterprise/replicasync"
"github.com/coder/coder/v2/testutil"
"github.com/coder/retry"
"github.com/coder/serpent"
)
func TestMain(m *testing.M) {
@@ -253,6 +256,90 @@ func TestEntitlements_HeaderWarnings(t *testing.T) {
})
}
func TestEntitlements_Prebuilds(t *testing.T) {
t.Parallel()
cases := []struct {
name string
experimentEnabled bool
featureEnabled bool
expectedEnabled bool
}{
{
name: "Fully enabled",
featureEnabled: true,
experimentEnabled: true,
expectedEnabled: true,
},
{
name: "Feature disabled",
featureEnabled: false,
experimentEnabled: true,
expectedEnabled: false,
},
{
name: "Experiment disabled",
featureEnabled: true,
experimentEnabled: false,
expectedEnabled: false,
},
{
name: "Fully disabled",
featureEnabled: false,
experimentEnabled: false,
expectedEnabled: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var prebuildsEntitled int64
if tc.featureEnabled {
prebuildsEntitled = 1
}
_, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) {
if tc.experimentEnabled {
values.Experiments = serpent.StringArray{string(codersdk.ExperimentWorkspacePrebuilds)}
}
}),
},
EntitlementsUpdateInterval: time.Second,
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: prebuildsEntitled,
},
},
})
// The entitlements will need to refresh before the reconciler is set.
require.Eventually(t, func() bool {
return api.AGPL.PrebuildsReconciler.Load() != nil
}, testutil.WaitSuperLong, testutil.IntervalFast)
reconciler := api.AGPL.PrebuildsReconciler.Load()
claimer := api.AGPL.PrebuildsClaimer.Load()
require.NotNil(t, reconciler)
require.NotNil(t, claimer)
if tc.expectedEnabled {
require.IsType(t, &prebuilds.StoreReconciler{}, *reconciler)
require.IsType(t, prebuilds.EnterpriseClaimer{}, *claimer)
} else {
require.Equal(t, &agplprebuilds.DefaultReconciler, reconciler)
require.Equal(t, &agplprebuilds.DefaultClaimer, claimer)
}
})
}
}
func TestAuditLogging(t *testing.T) {
t.Parallel()
t.Run("Enabled", func(t *testing.T) {

View File

@@ -38,6 +38,7 @@ type StoreReconciler struct {
clock quartz.Clock
cancelFn context.CancelCauseFunc
running atomic.Bool
stopped atomic.Bool
done chan struct{}
}
@@ -61,7 +62,7 @@ func NewStoreReconciler(
}
}
func (c *StoreReconciler) RunLoop(ctx context.Context) {
func (c *StoreReconciler) Run(ctx context.Context) {
reconciliationInterval := c.cfg.ReconciliationInterval.Value()
if reconciliationInterval <= 0 { // avoids a panic
reconciliationInterval = 5 * time.Minute
@@ -82,6 +83,11 @@ func (c *StoreReconciler) RunLoop(ctx context.Context) {
ctx, cancel := context.WithCancelCause(dbauthz.AsPrebuildsOrchestrator(ctx))
c.cancelFn = cancel
// Everything is in place, reconciler can now be considered as running.
//
// NOTE: without this atomic bool, Stop might race with Run for the c.cancelFn above.
c.running.Store(true)
for {
select {
// TODO: implement pubsub listener to allow reconciling a specific template imperatively once it has been changed,
@@ -107,16 +113,26 @@ func (c *StoreReconciler) RunLoop(ctx context.Context) {
}
func (c *StoreReconciler) Stop(ctx context.Context, cause error) {
defer c.running.Store(false)
if cause != nil {
c.logger.Error(context.Background(), "stopping reconciler due to an error", slog.Error(cause))
} else {
c.logger.Info(context.Background(), "gracefully stopping reconciler")
}
if c.isStopped() {
// If previously stopped (Swap returns previous value), then short-circuit.
//
// NOTE: we need to *prospectively* mark this as stopped to prevent Stop being called multiple times and causing problems.
if c.stopped.Swap(true) {
return
}
c.stopped.Store(true)
// If the reconciler is not running, there's nothing else to do.
if !c.running.Load() {
return
}
if c.cancelFn != nil {
c.cancelFn(cause)
}
@@ -138,10 +154,6 @@ func (c *StoreReconciler) Stop(ctx context.Context, cause error) {
}
}
func (c *StoreReconciler) isStopped() bool {
return c.stopped.Load()
}
// ReconcileAll will attempt to resolve the desired vs actual state of all templates which have presets with prebuilds configured.
//
// NOTE:

View File

@@ -575,7 +575,7 @@ func TestRunLoop(t *testing.T) {
t, &slogtest.Options{IgnoreErrors: true},
).Leveled(slog.LevelDebug)
db, pubSub := dbtestutil.NewDB(t)
controller := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock)
reconciler := prebuilds.NewStoreReconciler(db, pubSub, cfg, logger, clock)
ownerID := uuid.New()
dbgen.User(t, db, database.User{
@@ -639,7 +639,7 @@ func TestRunLoop(t *testing.T) {
// we need to wait until ticker is initialized, and only then use clock.Advance()
// otherwise clock.Advance() will be ignored
trap := clock.Trap().NewTicker()
go controller.RunLoop(ctx)
go reconciler.Run(ctx)
// wait until ticker is initialized
trap.MustWait(ctx).Release()
// start 1st iteration of ReconciliationLoop
@@ -681,7 +681,7 @@ func TestRunLoop(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
// gracefully stop the reconciliation loop
controller.Stop(ctx, nil)
reconciler.Stop(ctx, nil)
}
func TestFailedBuildBackoff(t *testing.T) {

View File

@@ -691,6 +691,7 @@ export interface DeploymentValues {
readonly notifications?: NotificationsConfig;
readonly additional_csp_policy?: string;
readonly workspace_hostname_suffix?: string;
readonly workspace_prebuilds?: PrebuildsConfig;
readonly config?: string;
readonly write_config?: boolean;
readonly address?: string;
@@ -773,6 +774,7 @@ export type Experiment =
| "example"
| "notifications"
| "web-push"
| "workspace-prebuilds"
| "workspace-usage";
// From codersdk/deployment.go
@@ -887,6 +889,7 @@ export type FeatureName =
| "user_limit"
| "user_role_management"
| "workspace_batch_actions"
| "workspace_prebuilds"
| "workspace_proxy";
export const FeatureNames: FeatureName[] = [
@@ -907,6 +910,7 @@ export const FeatureNames: FeatureName[] = [
"user_limit",
"user_role_management",
"workspace_batch_actions",
"workspace_prebuilds",
"workspace_proxy",
];