feat: allow templates to specify max_ttl or autostop_requirement (#10920)

This commit is contained in:
Dean Sheather
2023-12-15 18:27:56 +10:00
committed by GitHub
parent 30f032d282
commit b36071c6bb
46 changed files with 699 additions and 495 deletions
+1
View File
@@ -71,6 +71,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"icon": ActionTrack,
"default_ttl": ActionTrack,
"max_ttl": ActionTrack,
"use_max_ttl": ActionTrack,
"autostart_block_days_of_week": ActionTrack,
"autostop_requirement_days_of_week": ActionTrack,
"autostop_requirement_weeks": ActionTrack,
+6 -6
View File
@@ -448,15 +448,15 @@ USER QUIET HOURS SCHEDULE OPTIONS:
Allow users to set quiet hours schedules each day for workspaces to avoid
workspaces stopping during the day due to template max TTL.
--default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE
--default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE (default: CRON_TZ=UTC 0 0 * * *)
The default daily cron schedule applied to users that haven't set a
custom quiet hours schedule themselves. The quiet hours schedule
determines when workspaces will be force stopped due to the template's
max TTL, and will round the max TTL up to be within the user's quiet
hours window (or default). The format is the same as the standard cron
format, but the day-of-month, month and day-of-week must be *. Only
one hour and minute can be specified (ranges or comma separated values
are not supported).
autostop requirement, and will round the max deadline up to be within
the user's quiet hours window (or default). The format is the same as
the standard cron format, but the day-of-month, month and day-of-week
must be *. Only one hour and minute can be specified (ranges or comma
separated values are not supported).
⚠️ DANGEROUS OPTIONS:
--dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING
+8 -41
View File
@@ -492,12 +492,9 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureExternalTokenEncryption: len(api.ExternalTokenEncryption) > 0,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureAdvancedTemplateScheduling: true,
// FeatureTemplateAutostopRequirement depends on
// FeatureAdvancedTemplateScheduling.
codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "",
codersdk.FeatureWorkspaceProxy: true,
codersdk.FeatureUserRoleManagement: true,
codersdk.FeatureAccessControl: true,
codersdk.FeatureWorkspaceProxy: true,
codersdk.FeatureUserRoleManagement: true,
codersdk.FeatureAccessControl: true,
})
if err != nil {
return err
@@ -516,18 +513,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
return nil
}
if entitlements.Features[codersdk.FeatureTemplateAutostopRequirement].Enabled && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
api.entitlements.Errors = []string{
`Your license is entitled to the feature "template autostop ` +
`requirement" (and you have it enabled by setting the ` +
"default quiet hours schedule), but you are not entitled to " +
`the dependency feature "advanced template scheduling". ` +
"Please contact support for a new license.",
}
api.Logger.Error(ctx, "license is entitled to template autostop requirement but not advanced template scheduling")
return nil
}
featureChanged := func(featureName codersdk.FeatureName) (initial, changed, enabled bool) {
if api.entitlements.Features == nil {
return true, false, entitlements.Features[featureName].Enabled
@@ -579,21 +564,11 @@ func (api *API) updateEntitlements(ctx context.Context) error {
templateStore := schedule.NewEnterpriseTemplateScheduleStore(api.AGPL.UserQuietHoursScheduleStore)
templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore)
api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface)
} else {
templateStore := agplschedule.NewAGPLTemplateScheduleStore()
api.AGPL.TemplateScheduleStore.Store(&templateStore)
}
}
if initial, changed, enabled := featureChanged(codersdk.FeatureTemplateAutostopRequirement); shouldUpdate(initial, changed, enabled) {
if enabled {
templateStore := *(api.AGPL.TemplateScheduleStore.Load())
enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore)
if !ok {
api.Logger.Error(ctx, "unable to set up enterprise template schedule store, template autostop requirements will not be applied to workspace builds")
if api.DefaultQuietHoursSchedule == "" {
api.Logger.Warn(ctx, "template autostop requirement will default to UTC midnight as the default user quiet hours schedule. Set a custom default quiet hours schedule using CODER_QUIET_HOURS_DEFAULT_SCHEDULE to avoid this warning")
api.DefaultQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *"
}
enterpriseTemplateStore.UseAutostopRequirement.Store(true)
quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule)
if err != nil {
api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, template autostop requirements will not be applied to workspace builds", slog.Error(err))
@@ -601,16 +576,8 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore)
}
} else {
if api.DefaultQuietHoursSchedule != "" {
api.Logger.Warn(ctx, "template autostop requirements are not enabled (due to setting default quiet hours schedule) as your license is not entitled to this feature")
}
templateStore := *(api.AGPL.TemplateScheduleStore.Load())
enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore)
if ok {
enterpriseTemplateStore.UseAutostopRequirement.Store(false)
}
templateStore := agplschedule.NewAGPLTemplateScheduleStore()
api.AGPL.TemplateScheduleStore.Store(&templateStore)
quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore()
api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore)
}
@@ -47,19 +47,18 @@ func init() {
type Options struct {
*coderdtest.Options
AuditLogging bool
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
UserWorkspaceQuota int
ProxyHealthInterval time.Duration
LicenseOptions *LicenseOptions
NoDefaultQuietHoursSchedule bool
DontAddLicense bool
DontAddFirstUser bool
ReplicaSyncUpdateInterval time.Duration
ExternalTokenEncryption []dbcrypt.Cipher
ProvisionerDaemonPSK string
AuditLogging bool
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
UserWorkspaceQuota int
ProxyHealthInterval time.Duration
LicenseOptions *LicenseOptions
DontAddLicense bool
DontAddFirstUser bool
ReplicaSyncUpdateInterval time.Duration
ExternalTokenEncryption []dbcrypt.Cipher
ProvisionerDaemonPSK string
}
// New constructs a codersdk client connected to an in-memory Enterprise API instance.
@@ -86,10 +85,6 @@ func NewWithAPI(t *testing.T, options *Options) (
}
require.False(t, options.DontAddFirstUser && !options.DontAddLicense, "DontAddFirstUser requires DontAddLicense")
setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options)
if !options.NoDefaultQuietHoursSchedule && oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value() == "" {
err := oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Set("0 0 * * *")
require.NoError(t, err)
}
coderAPI, err := coderd.New(context.Background(), &coderd.Options{
RBAC: true,
AuditLogging: options.AuditLogging,
+15 -18
View File
@@ -2,6 +2,7 @@ package schedule
import (
"context"
"database/sql"
"sync/atomic"
"time"
@@ -21,12 +22,6 @@ import (
// EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that
// has all fields implemented for enterprise customers.
type EnterpriseTemplateScheduleStore struct {
// UseAutostopRequirement decides whether the AutostopRequirement field
// should be used instead of the MaxTTL field for determining the max
// deadline of a workspace build. This value is determined by a feature
// flag, licensing, and whether a default user quiet hours schedule is set.
UseAutostopRequirement atomic.Bool
// UserQuietHoursScheduleStore is used when recalculating build deadlines on
// update.
UserQuietHoursScheduleStore *atomic.Pointer[agpl.UserQuietHoursScheduleStore]
@@ -51,7 +46,7 @@ func (s *EnterpriseTemplateScheduleStore) now() time.Time {
}
// Get implements agpl.TemplateScheduleStore.
func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) {
func (*EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@@ -77,11 +72,11 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S
}
return agpl.TemplateScheduleOptions{
UserAutostartEnabled: tpl.AllowUserAutostart,
UserAutostopEnabled: tpl.AllowUserAutostop,
DefaultTTL: time.Duration(tpl.DefaultTTL),
MaxTTL: time.Duration(tpl.MaxTTL),
UseAutostopRequirement: s.UseAutostopRequirement.Load(),
UserAutostartEnabled: tpl.AllowUserAutostart,
UserAutostopEnabled: tpl.AllowUserAutostop,
DefaultTTL: time.Duration(tpl.DefaultTTL),
MaxTTL: time.Duration(tpl.MaxTTL),
UseMaxTTL: tpl.UseMaxTtl,
AutostopRequirement: agpl.TemplateAutostopRequirement{
DaysOfWeek: uint8(tpl.AutostopRequirementDaysOfWeek),
Weeks: tpl.AutostopRequirementWeeks,
@@ -108,6 +103,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
}
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
opts.UseMaxTTL != tpl.UseMaxTtl &&
int64(opts.MaxTTL) == tpl.MaxTTL &&
int16(opts.AutostopRequirement.DaysOfWeek) == tpl.AutostopRequirementDaysOfWeek &&
opts.AutostartRequirement.DaysOfWeek == tpl.AutostartAllowedDays() &&
@@ -142,6 +138,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
AllowUserAutostart: opts.UserAutostartEnabled,
AllowUserAutostop: opts.UserAutostopEnabled,
DefaultTTL: int64(opts.DefaultTTL),
UseMaxTtl: opts.UseMaxTTL,
MaxTTL: int64(opts.MaxTTL),
AutostopRequirementDaysOfWeek: int16(opts.AutostopRequirement.DaysOfWeek),
AutostopRequirementWeeks: opts.AutostopRequirement.Weeks,
@@ -184,7 +181,6 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
}
}
// TODO: update all workspace max_deadlines to be within new bounds
template, err = tx.GetTemplateByID(ctx, tpl.ID)
if err != nil {
return xerrors.Errorf("get updated template schedule: %w", err)
@@ -192,11 +188,9 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
// Recalculate max_deadline and deadline for all running workspace
// builds on this template.
if s.UseAutostopRequirement.Load() {
err = s.updateWorkspaceBuilds(ctx, tx, template)
if err != nil {
return xerrors.Errorf("update workspace builds: %w", err)
}
err = s.updateWorkspaceBuilds(ctx, tx, template)
if err != nil {
return xerrors.Errorf("update workspace builds: %w", err)
}
return nil
@@ -218,6 +212,9 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuilds(ctx context.Cont
ctx = dbauthz.AsSystemRestricted(ctx)
builds, err := db.GetActiveWorkspaceBuildsByTemplateID(ctx, template.ID)
if xerrors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return xerrors.Errorf("get active workspace builds: %w", err)
}
+10 -12
View File
@@ -214,16 +214,15 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) {
// Set the template policy.
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr)
templateScheduleStore.UseAutostopRequirement.Store(true)
templateScheduleStore.TimeNowFn = func() time.Time {
return c.now
}
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
UseAutostopRequirement: true,
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
UseMaxTTL: false,
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
// Every day
DaysOfWeek: 0b01111111,
@@ -498,16 +497,15 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
// Set the template policy.
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr)
templateScheduleStore.UseAutostopRequirement.Store(true)
templateScheduleStore.TimeNowFn = func() time.Time {
return now
}
_, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
UseAutostopRequirement: true,
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
UseMaxTTL: false,
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
// Every day
DaysOfWeek: 0b01111111,
+4 -10
View File
@@ -13,26 +13,20 @@ import (
func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// The experiment must be enabled.
if !api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) {
httpapi.RouteNotFound(rw)
return
}
// Entitlement must be enabled.
api.entitlementsMu.RLock()
entitled := api.entitlements.Features[codersdk.FeatureTemplateAutostopRequirement].Entitlement != codersdk.EntitlementNotEntitled
enabled := api.entitlements.Features[codersdk.FeatureTemplateAutostopRequirement].Enabled
entitled := api.entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Entitlement != codersdk.EntitlementNotEntitled
enabled := api.entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled
api.entitlementsMu.RUnlock()
if !entitled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Template autostop requirement is an Enterprise feature. Contact sales!",
Message: "Advanced template scheduling (and user quiet hours schedule) is an Enterprise feature. Contact sales!",
})
return
}
if !enabled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "Template autostop requirement feature is not enabled. Please specify a default user quiet hours schedule to use this feature.",
Message: "Advanced template scheduling (and user quiet hours schedule) is not enabled.",
})
return
}
+22 -63
View File
@@ -18,6 +18,26 @@ import (
func TestUserQuietHours(t *testing.T) {
t.Parallel()
t.Run("DefaultToUTC", func(t *testing.T) {
t.Parallel()
adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
},
},
})
client, user := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.UserQuietHoursSchedule(ctx, user.ID.String())
require.NoError(t, err)
require.Equal(t, "UTC", res.Timezone)
require.Equal(t, "00:00", res.Time)
require.Equal(t, "CRON_TZ=UTC 0 0 * * *", res.RawSchedule)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
@@ -35,7 +55,6 @@ func TestUserQuietHours(t *testing.T) {
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set(defaultQuietHoursSchedule)
dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement))
adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
@@ -43,8 +62,7 @@ func TestUserQuietHours(t *testing.T) {
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureTemplateAutostopRequirement: 1,
codersdk.FeatureAdvancedTemplateScheduling: 1,
},
},
})
@@ -137,7 +155,6 @@ func TestUserQuietHours(t *testing.T) {
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement))
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
@@ -145,9 +162,8 @@ func TestUserQuietHours(t *testing.T) {
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
// Not entitled.
// codersdk.FeatureTemplateAutostopRequirement: 1,
// codersdk.FeatureAdvancedTemplateScheduling: 1,
},
},
})
@@ -160,61 +176,4 @@ func TestUserQuietHours(t *testing.T) {
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
t.Run("NotEnabled", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set("")
dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement))
client, user := coderdenttest.New(t, &coderdenttest.Options{
NoDefaultQuietHoursSchedule: true,
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureTemplateAutostopRequirement: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // We want to test the lack of feature, not RBAC.
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
t.Run("NoFeatureFlag", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
dv.UserQuietHoursSchedule.DefaultSchedule.Set("")
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
codersdk.FeatureTemplateAutostopRequirement: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // We want to test the lack of feature, not RBAC.
_, err := client.UserQuietHoursSchedule(ctx, user.UserID.String())
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}