Hide prebuilds behind premium license & experiment

Signed-off-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Danny Kopping
2025-02-17 13:03:56 +00:00
parent f2bed85d64
commit 7498980c5f
8 changed files with 101 additions and 50 deletions

View File

@@ -31,8 +31,6 @@ import (
"sync/atomic"
"time"
"github.com/coder/coder/v2/coderd/prebuilds"
"github.com/charmbracelet/lipgloss"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-systemd/daemon"
@@ -943,11 +941,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
cliui.Info(inv.Stdout, "Notifications are currently disabled as there are no configured delivery methods. See https://coder.com/docs/admin/monitoring/notifications#delivery-methods for more details.")
}
// TODO: implement experiment and configs
prebuildsCtrl := prebuilds.NewController(options.Database, options.Pubsub, logger.Named("prebuilds.controller"))
go prebuildsCtrl.Loop(ctx)
defer prebuildsCtrl.Stop()
// Since errCh only has one buffered slot, all routines
// sending on it must be wrapped in a select/default to
// avoid leaving dangling goroutines waiting for the

7
coderd/apidoc/docs.go generated
View File

@@ -11504,19 +11504,22 @@ const docTemplate = `{
"example",
"auto-fill-parameters",
"notifications",
"workspace-usage"
"workspace-usage",
"workspace-prebuilds"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.",
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
},
"x-enum-varnames": [
"ExperimentExample",
"ExperimentAutoFillParameters",
"ExperimentNotifications",
"ExperimentWorkspaceUsage"
"ExperimentWorkspaceUsage",
"ExperimentWorkspacePrebuilds"
]
},
"codersdk.ExternalAuth": {

View File

@@ -10295,19 +10295,22 @@
"example",
"auto-fill-parameters",
"notifications",
"workspace-usage"
"workspace-usage",
"workspace-prebuilds"
],
"x-enum-comments": {
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
"ExperimentExample": "This isn't used for anything.",
"ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.",
"ExperimentWorkspacePrebuilds": "Enables the new workspace prebuilds feature.",
"ExperimentWorkspaceUsage": "Enables the new workspace usage tracking."
},
"x-enum-varnames": [
"ExperimentExample",
"ExperimentAutoFillParameters",
"ExperimentNotifications",
"ExperimentWorkspaceUsage"
"ExperimentWorkspaceUsage",
"ExperimentWorkspacePrebuilds"
]
},
"codersdk.ExternalAuth": {

View File

@@ -8,6 +8,7 @@ import (
"math"
mrand "math/rand"
"strings"
"sync/atomic"
"time"
"github.com/hashicorp/go-multierror"
@@ -34,66 +35,68 @@ import (
type Controller struct {
store database.Store
cfg codersdk.PrebuildsConfig
pubsub pubsub.Pubsub
logger slog.Logger
nudgeCh chan *uuid.UUID
closeCh chan struct{}
logger slog.Logger
nudgeCh chan *uuid.UUID
cancelFn context.CancelCauseFunc
closed atomic.Bool
}
func NewController(store database.Store, pubsub pubsub.Pubsub, logger slog.Logger) *Controller {
func NewController(store database.Store, pubsub pubsub.Pubsub, cfg codersdk.PrebuildsConfig, logger slog.Logger) *Controller {
return &Controller{
store: store,
pubsub: pubsub,
logger: logger,
cfg: cfg,
nudgeCh: make(chan *uuid.UUID, 1),
closeCh: make(chan struct{}, 1),
}
}
func (c Controller) Loop(ctx context.Context) {
ticker := time.NewTicker(time.Second * 5) // TODO: configurable? 1m probably lowest valid value
func (c *Controller) Loop(ctx context.Context) error {
ticker := time.NewTicker(c.cfg.ReconciliationInterval.Value())
defer ticker.Stop()
// TODO: create new authz role
ctx = dbauthz.AsSystemRestricted(ctx)
ctx, cancel := context.WithCancelCause(dbauthz.AsSystemRestricted(ctx))
c.cancelFn = cancel
// TODO: bounded concurrency?
var eg errgroup.Group
for {
select {
// Accept nudges from outside the control loop to trigger a new iteration.
case template := <-c.nudgeCh:
eg.Go(func() error {
c.reconcile(ctx, template)
return nil
})
c.reconcile(ctx, template)
// Trigger a new iteration on each tick.
case <-ticker.C:
eg.Go(func() error {
c.reconcile(ctx, nil)
return nil
})
case <-c.closeCh:
c.logger.Info(ctx, "control loop stopped")
goto wait
c.reconcile(ctx, nil)
case <-ctx.Done():
c.logger.Error(context.Background(), "control loop exited: %w", ctx.Err())
goto wait
c.logger.Error(context.Background(), "prebuilds reconciliation loop exited", slog.Error(ctx.Err()), slog.F("cause", context.Cause(ctx)))
return ctx.Err()
}
}
// TODO: no gotos
wait:
_ = eg.Wait()
}
func (c Controller) ReconcileTemplate(templateID uuid.UUID) {
func (c *Controller) Close(cause error) {
if c.isClosed() {
return
}
c.closed.Store(true)
if c.cancelFn != nil {
c.cancelFn(cause)
}
}
func (c *Controller) isClosed() bool {
return c.closed.Load()
}
func (c *Controller) ReconcileTemplate(templateID uuid.UUID) {
// TODO: replace this with pubsub listening
c.nudgeCh <- &templateID
}
func (c Controller) reconcile(ctx context.Context, templateID *uuid.UUID) {
func (c *Controller) reconcile(ctx context.Context, templateID *uuid.UUID) {
var logger slog.Logger
if templateID == nil {
logger = c.logger.With(slog.F("reconcile_context", "all"))
@@ -167,7 +170,7 @@ type reconciliationActions struct {
// calculateActions MUST be called within the context of a transaction (TODO: isolation)
// with an advisory lock to prevent TOCTOU races.
func (c Controller) calculateActions(ctx context.Context, template database.Template, state database.GetTemplatePrebuildStateRow) (*reconciliationActions, error) {
func (c *Controller) calculateActions(ctx context.Context, template database.Template, state database.GetTemplatePrebuildStateRow) (*reconciliationActions, error) {
// TODO: align workspace states with how we represent them on the FE and the CLI
// right now there's some slight differences which can lead to additional prebuilds being created
@@ -279,7 +282,7 @@ func (c Controller) calculateActions(ctx context.Context, template database.Temp
return actions, nil
}
func (c Controller) reconcileTemplate(ctx context.Context, template database.Template) error {
func (c *Controller) reconcileTemplate(ctx context.Context, template database.Template) error {
logger := c.logger.With(slog.F("template_id", template.ID.String()))
// get number of desired vs actual prebuild instances
@@ -360,7 +363,7 @@ func (c Controller) reconcileTemplate(ctx context.Context, template database.Tem
return nil
}
func (c Controller) createPrebuild(ctx context.Context, db database.Store, prebuildID uuid.UUID, template database.Template, presetID uuid.UUID) error {
func (c *Controller) createPrebuild(ctx context.Context, db database.Store, prebuildID uuid.UUID, template database.Template, presetID uuid.UUID) error {
name, err := generateName()
if err != nil {
return xerrors.Errorf("failed to generate unique prebuild ID: %w", err)
@@ -394,7 +397,7 @@ func (c Controller) createPrebuild(ctx context.Context, db database.Store, prebu
return c.provision(ctx, db, prebuildID, template, presetID, database.WorkspaceTransitionStart, workspace)
}
func (c Controller) deletePrebuild(ctx context.Context, db database.Store, prebuildID uuid.UUID, template database.Template, presetID uuid.UUID) error {
func (c *Controller) deletePrebuild(ctx context.Context, db database.Store, prebuildID uuid.UUID, template database.Template, presetID uuid.UUID) error {
workspace, err := db.GetWorkspaceByID(ctx, prebuildID)
if err != nil {
return xerrors.Errorf("get workspace by ID: %w", err)
@@ -406,7 +409,7 @@ func (c Controller) deletePrebuild(ctx context.Context, db database.Store, prebu
return c.provision(ctx, db, prebuildID, template, presetID, database.WorkspaceTransitionDelete, workspace)
}
func (c Controller) provision(ctx context.Context, db database.Store, prebuildID uuid.UUID, template database.Template, presetID uuid.UUID, transition database.WorkspaceTransition, workspace database.Workspace) error {
func (c *Controller) provision(ctx context.Context, db database.Store, prebuildID uuid.UUID, template database.Template, presetID uuid.UUID, transition database.WorkspaceTransition, workspace database.Workspace) error {
tvp, err := db.GetPresetParametersByTemplateVersionID(ctx, template.ActiveVersionID)
if err != nil {
return xerrors.Errorf("fetch preset details: %w", err)
@@ -464,10 +467,6 @@ func (c Controller) provision(ctx context.Context, db database.Store, prebuildID
return nil
}
func (c Controller) Stop() {
c.closeCh <- struct{}{}
}
// generateName generates a 20-byte prebuild name which should safe to use without truncation in most situations.
// UUIDs may be too long for a resource name in cloud providers (since this ID will be used in the prebuild's name).
//

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]
}
@@ -393,6 +396,7 @@ type DeploymentValues struct {
TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"`
Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"`
AdditionalCSPPolicy serpent.StringArray `json:"additional_csp_policy,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"`
@@ -747,6 +751,10 @@ type NotificationsWebhookConfig struct {
Endpoint serpent.URL `json:"endpoint" typescript:",notnull"`
}
type PrebuildsConfig struct {
ReconciliationInterval serpent.Duration `json:"reconciliation_interval" typescript:",notnull"`
}
const (
annotationFormatDuration = "format_duration"
annotationEnterpriseKey = "enterprise"
@@ -977,6 +985,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.",
}
)
httpAddress := serpent.Option{
@@ -2897,6 +2910,16 @@ 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.
},
{
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",
},
}
return opts
@@ -3118,13 +3141,16 @@ const (
ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature.
ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events.
ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking.
ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature.
)
// ExperimentsAll 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 ExperimentsAll = Experiments{}
var ExperimentsAll = Experiments{
ExperimentWorkspacePrebuilds,
}
// Experiments is a list of experiments.
// Multiple experiments may be enabled at the same time.

View File

@@ -2789,6 +2789,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `auto-fill-parameters` |
| `notifications` |
| `workspace-usage` |
| `workspace-prebuilds` |
## codersdk.ExternalAuth

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"fmt"
"github.com/coder/coder/v2/coderd/prebuilds"
"math"
"net/http"
"net/url"
@@ -581,6 +582,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
}
go api.runEntitlementsLoop(ctx)
if api.AGPL.Experiments.Enabled(codersdk.ExperimentWorkspacePrebuilds) {
if !api.Entitlements.Enabled(codersdk.FeatureWorkspacePrebuilds) {
options.Logger.Warn(ctx, "prebuilds experiment enabled but not entitled to use")
} else {
api.prebuildsController = prebuilds.NewController(options.Database, options.Pubsub, options.DeploymentValues.Prebuilds, options.Logger.Named("prebuilds.controller"))
go api.prebuildsController.Loop(ctx)
}
}
return api, nil
}
@@ -634,6 +644,8 @@ type API struct {
licenseMetricsCollector *license.MetricsCollector
tailnetService *tailnet.ClientService
prebuildsController *prebuilds.Controller
}
// writeEntitlementWarningsHeader writes the entitlement warnings to the response header
@@ -664,6 +676,11 @@ func (api *API) Close() error {
if api.Options.CheckInactiveUsersCancelFunc != nil {
api.Options.CheckInactiveUsersCancelFunc()
}
if api.prebuildsController != nil {
api.prebuildsController.Close(xerrors.New("api closed")) // TODO: determine root cause (requires changes up the stack, though).
}
return api.AGPL.Close()
}

View File

@@ -668,6 +668,7 @@ export interface DeploymentValues {
readonly terms_of_service_url?: string;
readonly notifications?: NotificationsConfig;
readonly additional_csp_policy?: string;
readonly workspace_prebuilds?: PrebuildsConfig;
readonly config?: string;
readonly write_config?: boolean;
readonly address?: string;
@@ -735,6 +736,7 @@ export type Experiment =
| "auto-fill-parameters"
| "example"
| "notifications"
| "workspace-prebuilds"
| "workspace-usage";
// From codersdk/deployment.go
@@ -849,6 +851,7 @@ export type FeatureName =
| "user_limit"
| "user_role_management"
| "workspace_batch_actions"
| "workspace_prebuilds"
| "workspace_proxy";
export const FeatureNames: FeatureName[] = [
@@ -869,6 +872,7 @@ export const FeatureNames: FeatureName[] = [
"user_limit",
"user_role_management",
"workspace_batch_actions",
"workspace_prebuilds",
"workspace_proxy",
];
@@ -1554,6 +1558,11 @@ export interface PprofConfig {
readonly address: string;
}
// From codersdk/deployment.go
export interface PrebuildsConfig {
readonly reconciliation_interval: number;
}
// From codersdk/presets.go
export interface Preset {
readonly ID: string;