feat: allow disabling autostart and custom autostop for template (#6933)

API only, frontend in upcoming PR.
This commit is contained in:
Dean Sheather
2023-04-04 22:48:35 +10:00
committed by GitHub
parent 083fc89f93
commit e33941b7c2
65 changed files with 1433 additions and 486 deletions
+4 -1
View File
@@ -30,6 +30,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@@ -72,6 +73,7 @@ import (
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/prometheusmetrics"
"github.com/coder/coder/coderd/schedule"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/updatecheck"
@@ -632,6 +634,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
LoginRateLimit: loginRateLimit,
FilesRateLimit: filesRateLimit,
HTTPClient: httpClient,
TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{},
SSHConfig: codersdk.SSHConfigResponse{
HostnamePrefix: cfg.SSHConfig.DeploymentName.String(),
SSHConfigOptions: configSSHOptions,
@@ -1019,7 +1022,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
autobuildPoller := time.NewTicker(cfg.AutobuildPollInterval.Value())
defer autobuildPoller.Stop()
autobuildExecutor := executor.New(ctx, options.Database, logger, autobuildPoller.C)
autobuildExecutor := executor.New(ctx, options.Database, coderAPI.TemplateScheduleStore, logger, autobuildPoller.C)
autobuildExecutor.Run()
// Currently there is no way to ask the server to shut
+19 -3
View File
@@ -21,6 +21,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
defaultTTL time.Duration
maxTTL time.Duration
allowUserCancelWorkspaceJobs bool
allowUserAutostart bool
allowUserAutostop bool
)
client := new(codersdk.Client)
@@ -32,17 +34,17 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
),
Short: "Edit the metadata of a template by name.",
Handler: func(inv *clibase.Invocation) error {
if maxTTL != 0 {
if maxTTL != 0 || !allowUserAutostart || !allowUserAutostop {
entitlements, err := client.Entitlements(inv.Context())
var sdkErr *codersdk.Error
if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl")
return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl, --allow-user-autostart=false or --allow-user-autostop=false")
} else if err != nil {
return xerrors.Errorf("get entitlements: %w", err)
}
if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl")
return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl, --allow-user-autostart=false or --allow-user-autostop=false")
}
}
@@ -64,6 +66,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
DefaultTTLMillis: defaultTTL.Milliseconds(),
MaxTTLMillis: maxTTL.Milliseconds(),
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
AllowUserAutostart: allowUserAutostart,
AllowUserAutostop: allowUserAutostop,
}
_, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req)
@@ -112,6 +116,18 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
Default: "true",
Value: clibase.BoolOf(&allowUserCancelWorkspaceJobs),
},
{
Flag: "allow-user-autostart",
Description: "Allow users to configure autostart for workspaces on this template. This can only be disabled in enterprise.",
Default: "true",
Value: clibase.BoolOf(&allowUserAutostart),
},
{
Flag: "allow-user-autostop",
Description: "Allow users to customize the autostop TTL for workspaces on this template. This can only be disabled in enterprise.",
Default: "true",
Value: clibase.BoolOf(&allowUserAutostop),
},
cliui.SkipPromptOption(),
}
+234 -1
View File
@@ -428,7 +428,8 @@ func TestTemplateEdit(t *testing.T) {
require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled))
// Assert that the template metadata did not change.
// Assert that the template metadata did not change. We verify the
// correct request gets sent to the server already.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, template.Name, updated.Name)
@@ -439,4 +440,236 @@ func TestTemplateEdit(t *testing.T) {
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
})
})
t.Run("AllowUserScheduling", func(t *testing.T) {
t.Parallel()
t.Run("BlockedAGPL", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.DefaultTTLMillis = nil
ctr.MaxTTLMillis = nil
})
// Test the cli command with --allow-user-autostart.
cmdArgs := []string{
"templates",
"edit",
template.Name,
"--allow-user-autostart=false",
}
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitLong)
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, "appears to be an AGPL deployment")
// Test the cli command with --allow-user-autostop.
cmdArgs = []string{
"templates",
"edit",
template.Name,
"--allow-user-autostop=false",
}
inv, root = clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
ctx = testutil.Context(t, testutil.WaitLong)
err = inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, "appears to be an AGPL deployment")
// Assert that the template metadata did not change.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, template.Name, updated.Name)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.Icon, updated.Icon)
assert.Equal(t, template.DisplayName, updated.DisplayName)
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart)
assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop)
})
t.Run("BlockedNotEntitled", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
// Make a proxy server that will return a valid entitlements
// response, but without advanced scheduling entitlement.
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/entitlements" {
res := codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{},
Warnings: []string{},
Errors: []string{},
HasLicense: true,
Trial: true,
RequireTelemetry: false,
}
for _, feature := range codersdk.FeatureNames {
res.Features[feature] = codersdk.Feature{
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: false,
Limit: nil,
Actual: nil,
}
}
httpapi.Write(r.Context(), w, http.StatusOK, res)
return
}
// Otherwise, proxy the request to the real API server.
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
}))
defer proxy.Close()
// Create a new client that uses the proxy server.
proxyURL, err := url.Parse(proxy.URL)
require.NoError(t, err)
proxyClient := codersdk.New(proxyURL)
proxyClient.SetSessionToken(client.SessionToken())
// Test the cli command with --allow-user-autostart.
cmdArgs := []string{
"templates",
"edit",
template.Name,
"--allow-user-autostart=false",
}
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, proxyClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
err = inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, "license is not entitled")
// Test the cli command with --allow-user-autostop.
cmdArgs = []string{
"templates",
"edit",
template.Name,
"--allow-user-autostop=false",
}
inv, root = clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, proxyClient, root)
ctx = testutil.Context(t, testutil.WaitLong)
err = inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, "license is not entitled")
// Assert that the template metadata did not change.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, template.Name, updated.Name)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.Icon, updated.Icon)
assert.Equal(t, template.DisplayName, updated.DisplayName)
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart)
assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop)
})
t.Run("Entitled", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
// Make a proxy server that will return a valid entitlements
// response, including a valid advanced scheduling entitlement.
var updateTemplateCalled int64
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/entitlements" {
res := codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{},
Warnings: []string{},
Errors: []string{},
HasLicense: true,
Trial: true,
RequireTelemetry: false,
}
for _, feature := range codersdk.FeatureNames {
var one int64 = 1
res.Features[feature] = codersdk.Feature{
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: true,
Limit: &one,
Actual: &one,
}
}
httpapi.Write(r.Context(), w, http.StatusOK, res)
return
}
if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
_ = r.Body.Close()
var req codersdk.UpdateTemplateMeta
err = json.Unmarshal(body, &req)
require.NoError(t, err)
assert.False(t, req.AllowUserAutostart)
assert.False(t, req.AllowUserAutostop)
r.Body = io.NopCloser(bytes.NewReader(body))
atomic.AddInt64(&updateTemplateCalled, 1)
// We still want to call the real route.
}
// Otherwise, proxy the request to the real API server.
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
}))
defer proxy.Close()
// Create a new client that uses the proxy server.
proxyURL, err := url.Parse(proxy.URL)
require.NoError(t, err)
proxyClient := codersdk.New(proxyURL)
proxyClient.SetSessionToken(client.SessionToken())
// Test the cli command.
cmdArgs := []string{
"templates",
"edit",
template.Name,
"--allow-user-autostart=false",
"--allow-user-autostop=false",
}
inv, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, proxyClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled))
// Assert that the template metadata did not change. We verify the
// correct request gets sent to the server already.
updated, err := client.Template(context.Background(), template.ID)
require.NoError(t, err)
assert.Equal(t, template.Name, updated.Name)
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.Icon, updated.Icon)
assert.Equal(t, template.DisplayName, updated.DisplayName)
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart)
assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop)
})
})
}
+8
View File
@@ -3,6 +3,14 @@ Usage: coder templates edit [flags] <template>
Edit the metadata of a template by name.
Options
--allow-user-autostart bool (default: true)
Allow users to configure autostart for workspaces on this template.
This can only be disabled in enterprise.
--allow-user-autostop bool (default: true)
Allow users to customize the autostop TTL for workspaces on this
template. This can only be disabled in enterprise.
--allow-user-cancel-workspace-jobs bool (default: true)
Allow users to cancel in-progress workspace jobs.
+5 -28
View File
@@ -16,29 +16,6 @@ import (
"github.com/coder/coder/testutil"
)
type mockTemplateScheduleStore struct {
getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error)
setFn func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error)
}
var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{}
func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) {
if m.getFn != nil {
return m.getFn(ctx, db, templateID)
}
return schedule.NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID)
}
func (m mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
if m.setFn != nil {
return m.setFn(ctx, db, template, options)
}
return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options)
}
func TestWorkspaceActivityBump(t *testing.T) {
t.Parallel()
@@ -57,12 +34,12 @@ func TestWorkspaceActivityBump(t *testing.T) {
// Agent stats trigger the activity bump, so we want to report
// very frequently in tests.
AgentStatsRefreshInterval: time.Millisecond * 100,
TemplateScheduleStore: mockTemplateScheduleStore{
getFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) {
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
GetFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserSchedulingEnabled: true,
DefaultTTL: ttl,
MaxTTL: maxTTL,
UserAutostopEnabled: true,
DefaultTTL: ttl,
MaxTTL: maxTTL,
}, nil
},
},
+15
View File
@@ -6401,6 +6401,14 @@ const docTemplate = `{
"template_version_id"
],
"properties": {
"allow_user_autostart": {
"description": "AllowUserAutostart allows users to set a schedule for autostarting their\nworkspace. By default this is true. This can only be disabled when using\nan enterprise license.",
"type": "boolean"
},
"allow_user_autostop": {
"description": "AllowUserAutostop allows users to set a custom workspace TTL to use in\nplace of the template's DefaultTTL field. By default this is true. If\nfalse, the DefaultTTL will always be used. This can only be disabled when\nusing an enterprise license.",
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"description": "Allow users to cancel in-progress workspace jobs.\n*bool as the default value is \"true\".",
"type": "boolean"
@@ -8188,6 +8196,13 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"allow_user_autostart": {
"description": "AllowUserAutostart and AllowUserAutostop are enterprise-only. Their\nvalues are only used if your license is entitled to use the advanced\ntemplate scheduling feature.",
"type": "boolean"
},
"allow_user_autostop": {
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"type": "boolean"
},
+15
View File
@@ -5703,6 +5703,14 @@
"type": "object",
"required": ["name", "template_version_id"],
"properties": {
"allow_user_autostart": {
"description": "AllowUserAutostart allows users to set a schedule for autostarting their\nworkspace. By default this is true. This can only be disabled when using\nan enterprise license.",
"type": "boolean"
},
"allow_user_autostop": {
"description": "AllowUserAutostop allows users to set a custom workspace TTL to use in\nplace of the template's DefaultTTL field. By default this is true. If\nfalse, the DefaultTTL will always be used. This can only be disabled when\nusing an enterprise license.",
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"description": "Allow users to cancel in-progress workspace jobs.\n*bool as the default value is \"true\".",
"type": "boolean"
@@ -7361,6 +7369,13 @@
"type": "string",
"format": "uuid"
},
"allow_user_autostart": {
"description": "AllowUserAutostart and AllowUserAutostop are enterprise-only. Their\nvalues are only used if your license is entitled to use the advanced\ntemplate scheduling feature.",
"type": "boolean"
},
"allow_user_autostop": {
"type": "boolean"
},
"allow_user_cancel_workspace_jobs": {
"type": "boolean"
},
+40 -28
View File
@@ -3,6 +3,7 @@ package executor
import (
"context"
"encoding/json"
"sync/atomic"
"time"
"github.com/google/uuid"
@@ -18,11 +19,12 @@ import (
// Executor automatically starts or stops workspaces.
type Executor struct {
ctx context.Context
db database.Store
log slog.Logger
tick <-chan time.Time
statsCh chan<- Stats
ctx context.Context
db database.Store
templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
log slog.Logger
tick <-chan time.Time
statsCh chan<- Stats
}
// Stats contains information about one run of Executor.
@@ -33,13 +35,14 @@ type Stats struct {
}
// New returns a new autobuild executor.
func New(ctx context.Context, db database.Store, log slog.Logger, tick <-chan time.Time) *Executor {
func New(ctx context.Context, db database.Store, tss *atomic.Pointer[schedule.TemplateScheduleStore], log slog.Logger, tick <-chan time.Time) *Executor {
le := &Executor{
//nolint:gocritic // Autostart has a limited set of permissions.
ctx: dbauthz.AsAutostart(ctx),
db: db,
tick: tick,
log: log,
ctx: dbauthz.AsAutostart(ctx),
db: db,
templateScheduleStore: tss,
tick: tick,
log: log,
}
return le
}
@@ -102,21 +105,11 @@ func (e *Executor) runOnce(t time.Time) Stats {
// NOTE: If a workspace build is created with a given TTL and then the user either
// changes or unsets the TTL, the deadline for the workspace build will not
// have changed. This behavior is as expected per #2229.
workspaceRows, err := e.db.GetWorkspaces(e.ctx, database.GetWorkspacesParams{
Deleted: false,
})
workspaces, err := e.db.GetWorkspacesEligibleForAutoStartStop(e.ctx, t)
if err != nil {
e.log.Error(e.ctx, "get workspaces for autostart or autostop", slog.Error(err))
return stats
}
workspaces := database.ConvertWorkspaceRows(workspaceRows)
var eligibleWorkspaceIDs []uuid.UUID
for _, ws := range workspaces {
if isEligibleForAutoStartStop(ws) {
eligibleWorkspaceIDs = append(eligibleWorkspaceIDs, ws.ID)
}
}
// We only use errgroup here for convenience of API, not for early
// cancellation. This means we only return nil errors in th eg.Go.
@@ -124,8 +117,8 @@ func (e *Executor) runOnce(t time.Time) Stats {
// Limit the concurrency to avoid overloading the database.
eg.SetLimit(10)
for _, wsID := range eligibleWorkspaceIDs {
wsID := wsID
for _, ws := range workspaces {
wsID := ws.ID
log := e.log.With(slog.F("workspace_id", wsID))
eg.Go(func() error {
@@ -137,9 +130,6 @@ func (e *Executor) runOnce(t time.Time) Stats {
log.Error(e.ctx, "get workspace autostart failed", slog.Error(err))
return nil
}
if !isEligibleForAutoStartStop(ws) {
return nil
}
// Determine the workspace state based on its latest build.
priorHistory, err := db.GetLatestWorkspaceBuildByWorkspaceID(e.ctx, ws.ID)
@@ -148,6 +138,16 @@ func (e *Executor) runOnce(t time.Time) Stats {
return nil
}
templateSchedule, err := (*(e.templateScheduleStore.Load())).GetTemplateScheduleOptions(e.ctx, db, ws.TemplateID)
if err != nil {
log.Warn(e.ctx, "get template schedule options", slog.Error(err))
return nil
}
if !isEligibleForAutoStartStop(ws, priorHistory, templateSchedule) {
return nil
}
priorJob, err := db.GetProvisionerJobByID(e.ctx, priorHistory.JobID)
if err != nil {
log.Warn(e.ctx, "get last provisioner job for workspace %q: %w", slog.Error(err))
@@ -198,8 +198,20 @@ func (e *Executor) runOnce(t time.Time) Stats {
return stats
}
func isEligibleForAutoStartStop(ws database.Workspace) bool {
return !ws.Deleted && (ws.AutostartSchedule.String != "" || ws.Ttl.Int64 > 0)
func isEligibleForAutoStartStop(ws database.Workspace, priorHistory database.WorkspaceBuild, templateSchedule schedule.TemplateScheduleOptions) bool {
if ws.Deleted {
return false
}
if templateSchedule.UserAutostartEnabled && ws.AutostartSchedule.Valid && ws.AutostartSchedule.String != "" {
return true
}
// Don't check the template schedule to see whether it allows autostop, this
// is done during the build when determining the deadline.
if priorHistory.Transition == database.WorkspaceTransitionStart && !priorHistory.Deadline.IsZero() {
return true
}
return false
}
func getNextTransition(
@@ -6,9 +6,10 @@ import (
"testing"
"time"
"go.uber.org/goleak"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"github.com/coder/coder/coderd/autobuild/executor"
"github.com/coder/coder/coderd/coderdtest"
@@ -18,9 +19,6 @@ import (
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExecutorAutostartOK(t *testing.T) {
@@ -445,7 +443,7 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
workspace = mustProvisionWorkspace(t, client)
)
// Given: the user changes their mind and decides their workspace should not auto-stop
// Given: the user changes their mind and decides their workspace should not autostop
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
require.NoError(t, err)
@@ -471,7 +469,7 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
// Start the workspace again
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
// Given: the user changes their mind again and wants to enable auto-stop
// Given: the user changes their mind again and wants to enable autostop
newTTL := 8 * time.Hour
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: ptr.Ref(newTTL.Milliseconds())})
require.NoError(t, err)
@@ -605,6 +603,51 @@ func TestExecutorAutostartWithParameters(t *testing.T) {
mustWorkspaceParameters(t, client, workspace.LatestBuild.ID)
}
func TestExecutorAutostartTemplateDisabled(t *testing.T) {
t.Parallel()
var (
sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *")
tickCh = make(chan time.Time)
statsCh = make(chan executor.Stats)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
AutobuildStats: statsCh,
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: true,
DefaultTTL: 0,
MaxTTL: 0,
}, nil
},
},
})
// futureTime = time.Now().Add(time.Hour)
// futureTimeCron = fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour())
// Given: we have a user with a workspace configured to autostart some time in the future
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = ptr.Ref(sched.String())
})
)
// Given: workspace is stopped
workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// When: the autobuild executor ticks before the next scheduled time
go func() {
tickCh <- sched.Next(workspace.LatestBuild.CreatedAt).Add(time.Minute)
close(tickCh)
}()
// Then: nothing should happen
stats := <-statsCh
assert.NoError(t, stats.Error)
assert.Len(t, stats.Transitions, 0)
}
func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace {
t.Helper()
user := coderdtest.CreateFirstUser(t, client)
+9 -6
View File
@@ -122,7 +122,7 @@ type Options struct {
DERPMap *tailcfg.DERPMap
SwaggerEndpoint bool
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
TemplateScheduleStore schedule.TemplateScheduleStore
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
// AppSigningKey denotes the symmetric key to use for signing temporary app
// tokens. The key must be 64 bytes long.
AppSigningKey []byte
@@ -235,7 +235,11 @@ func New(options *Options) *API {
}
}
if options.TemplateScheduleStore == nil {
options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore()
options.TemplateScheduleStore = &atomic.Pointer[schedule.TemplateScheduleStore]{}
}
if options.TemplateScheduleStore.Load() == nil {
v := schedule.NewAGPLTemplateScheduleStore()
options.TemplateScheduleStore.Store(&v)
}
if len(options.AppSigningKey) != 64 {
panic("coderd: AppSigningKey must be 64 bytes long")
@@ -309,7 +313,7 @@ func New(options *Options) *API {
),
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{},
TemplateScheduleStore: options.TemplateScheduleStore,
Experiments: experiments,
healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{},
}
@@ -327,7 +331,6 @@ func New(options *Options) *API {
}
api.Auditor.Store(&options.Auditor)
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
@@ -770,7 +773,7 @@ type API struct {
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
TailnetCoordinator atomic.Pointer[tailnet.Coordinator]
QuotaCommitter atomic.Pointer[proto.QuotaCommitter]
TemplateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore]
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
HTTPAuth *HTTPAuthorizer
@@ -882,7 +885,7 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti
Tags: tags,
QuotaCommitter: &api.QuotaCommitter,
Auditor: &api.Auditor,
TemplateScheduleStore: &api.TemplateScheduleStore,
TemplateScheduleStore: api.TemplateScheduleStore,
AcquireJobDebounce: debounce,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
})
+9 -1
View File
@@ -26,6 +26,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -214,10 +215,17 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
options.FilesRateLimit = -1
}
var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore]
if options.TemplateScheduleStore == nil {
options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore()
}
templateScheduleStore.Store(&options.TemplateScheduleStore)
ctx, cancelFunc := context.WithCancel(context.Background())
lifecycleExecutor := executor.New(
ctx,
options.Database,
&templateScheduleStore,
slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug),
options.AutobuildTicker,
).WithStatsChannel(options.AutobuildStats)
@@ -311,7 +319,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
FilesRateLimit: options.FilesRateLimit,
Authorizer: options.Authorizer,
Telemetry: telemetry.NewNoop(),
TemplateScheduleStore: options.TemplateScheduleStore,
TemplateScheduleStore: &templateScheduleStore,
TLSCertificates: options.TLSCertificates,
TrialGenerator: options.TrialGenerator,
DERPMap: &tailcfg.DERPMap{
+4
View File
@@ -306,6 +306,10 @@ func (q *querier) GetDeploymentWorkspaceStats(ctx context.Context) (database.Get
return q.db.GetDeploymentWorkspaceStats(ctx)
}
func (q *querier) GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]database.Workspace, error) {
return q.db.GetWorkspacesEligibleForAutoStartStop(ctx, now)
}
func (q *querier) GetParameterSchemasCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.ParameterSchema, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
+29
View File
@@ -1902,6 +1902,8 @@ func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database
if tpl.ID != arg.ID {
continue
}
tpl.AllowUserAutostart = arg.AllowUserAutostart
tpl.AllowUserAutostop = arg.AllowUserAutostop
tpl.UpdatedAt = database.Now()
tpl.DefaultTTL = arg.DefaultTTL
tpl.MaxTTL = arg.MaxTTL
@@ -2903,6 +2905,8 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl
DisplayName: arg.DisplayName,
Icon: arg.Icon,
AllowUserCancelWorkspaceJobs: arg.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: true,
AllowUserAutostop: true,
}
q.templates = append(q.templates, template)
return template.DeepCopy(), nil
@@ -3977,6 +3981,31 @@ func (q *fakeQuerier) GetWorkspaceAgentStats(_ context.Context, createdAfter tim
return stats, nil
}
func (q *fakeQuerier) GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := []database.Workspace{}
for _, workspace := range q.workspaces {
build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID)
if err != nil {
return nil, err
}
if build.Transition == database.WorkspaceTransitionStart && !build.Deadline.IsZero() && build.Deadline.Before(now) {
workspaces = append(workspaces, workspace)
continue
}
if build.Transition == database.WorkspaceTransitionStop && workspace.AutostartSchedule.Valid {
workspaces = append(workspaces, workspace)
continue
}
}
return workspaces, nil
}
func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error {
if err := validateDatabaseType(arg); err != nil {
return err
+8 -2
View File
@@ -445,15 +445,21 @@ CREATE TABLE templates (
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
display_name character varying(64) DEFAULT ''::character varying NOT NULL,
allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL,
max_ttl bigint DEFAULT '0'::bigint NOT NULL
max_ttl bigint DEFAULT '0'::bigint NOT NULL,
allow_user_autostart boolean DEFAULT true NOT NULL,
allow_user_autostop boolean DEFAULT true NOT NULL
);
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
COMMENT ON COLUMN templates.display_name IS 'Display name is a custom, human-friendly template name that user can set.';
COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs IS 'Allow users to cancel in-progress workspace jobs.';
COMMENT ON COLUMN templates.allow_user_autostart IS 'Allow users to specify an autostart schedule for workspaces (enterprise).';
COMMENT ON COLUMN templates.allow_user_autostop IS 'Allow users to specify custom autostop values for workspaces (enterprise).';
CREATE TABLE user_links (
user_id uuid NOT NULL,
login_type login_type NOT NULL,
@@ -3,4 +3,4 @@ ALTER TABLE "templates" DROP COLUMN "min_autostart_interval";
-- rename "max_ttl" to "default_ttl" on "templates" table
ALTER TABLE "templates" RENAME COLUMN "max_ttl" TO "default_ttl";
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
@@ -0,0 +1,3 @@
ALTER TABLE "templates"
DROP COLUMN "allow_user_autostart",
DROP COLUMN "allow_user_autostop";
@@ -0,0 +1,9 @@
ALTER TABLE "templates"
ADD COLUMN "allow_user_autostart" boolean DEFAULT true NOT NULL,
ADD COLUMN "allow_user_autostop" boolean DEFAULT true NOT NULL;
COMMENT ON COLUMN "templates"."allow_user_autostart"
IS 'Allow users to specify an autostart schedule for workspaces (enterprise).';
COMMENT ON COLUMN "templates"."allow_user_autostop"
IS 'Allow users to specify custom autostop values for workspaces (enterprise).';
+2
View File
@@ -78,6 +78,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
); err != nil {
return nil, err
}
+5 -1
View File
@@ -1415,7 +1415,7 @@ type Template struct {
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
Description string `db:"description" json:"description"`
// The default duration for auto-stop for workspaces created from this template.
// The default duration for autostop for workspaces created from this template.
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
Icon string `db:"icon" json:"icon"`
@@ -1426,6 +1426,10 @@ type Template struct {
// Allow users to cancel in-progress workspace jobs.
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
// Allow users to specify an autostart schedule for workspaces (enterprise).
AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"`
// Allow users to specify custom autostop values for workspaces (enterprise).
AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"`
}
type TemplateVersion struct {
+1
View File
@@ -153,6 +153,7 @@ type sqlcQuerier interface {
GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error)
GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error)
GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error)
GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]Workspace, error)
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
// We use the organization_id as the id
// for simplicity since all users is
+111 -14
View File
@@ -3195,7 +3195,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem
const getTemplateByID = `-- name: GetTemplateByID :one
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
FROM
templates
WHERE
@@ -3225,13 +3225,15 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
)
return i, err
}
const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
FROM
templates
WHERE
@@ -3269,12 +3271,14 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
)
return i, err
}
const getTemplates = `-- name: GetTemplates :many
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl FROM templates
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop FROM templates
ORDER BY (name, id) ASC
`
@@ -3305,6 +3309,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
); err != nil {
return nil, err
}
@@ -3321,7 +3327,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many
SELECT
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
FROM
templates
WHERE
@@ -3389,6 +3395,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
); err != nil {
return nil, err
}
@@ -3422,7 +3430,7 @@ INSERT INTO
allow_user_cancel_workspace_jobs
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
`
type InsertTemplateParams struct {
@@ -3478,6 +3486,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
)
return i, err
}
@@ -3491,7 +3501,7 @@ SET
WHERE
id = $3
RETURNING
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
`
type UpdateTemplateACLByIDParams struct {
@@ -3521,6 +3531,8 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
)
return i, err
}
@@ -3580,7 +3592,7 @@ SET
WHERE
id = $1
RETURNING
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
`
type UpdateTemplateMetaByIDParams struct {
@@ -3622,6 +3634,8 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
)
return i, err
}
@@ -3631,25 +3645,31 @@ UPDATE
templates
SET
updated_at = $2,
default_ttl = $3,
max_ttl = $4
allow_user_autostart = $3,
allow_user_autostop = $4,
default_ttl = $5,
max_ttl = $6
WHERE
id = $1
RETURNING
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop
`
type UpdateTemplateScheduleByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"`
AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"`
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
MaxTTL int64 `db:"max_ttl" json:"max_ttl"`
}
func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) {
row := q.db.QueryRowContext(ctx, updateTemplateScheduleByID,
arg.ID,
arg.UpdatedAt,
arg.AllowUserAutostart,
arg.AllowUserAutostop,
arg.DefaultTTL,
arg.MaxTTL,
)
@@ -3672,6 +3692,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT
&i.DisplayName,
&i.AllowUserCancelWorkspaceJobs,
&i.MaxTTL,
&i.AllowUserAutostart,
&i.AllowUserAutostop,
)
return i, err
}
@@ -7906,6 +7928,81 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
return items, nil
}
const getWorkspacesEligibleForAutoStartStop = `-- name: GetWorkspacesEligibleForAutoStartStop :many
SELECT
workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at
FROM
workspaces
LEFT JOIN
workspace_builds ON workspace_builds.workspace_id = workspaces.id
WHERE
workspace_builds.build_number = (
SELECT
MAX(build_number)
FROM
workspace_builds
WHERE
workspace_builds.workspace_id = workspaces.id
) AND
(
-- If the workspace build was a start transition, the workspace is
-- potentially eligible for autostop if it's past the deadline. The
-- deadline is computed at build time upon success and is bumped based
-- on activity (up the max deadline if set). We don't need to check
-- license here since that's done when the values are written to the build.
(
workspace_builds.transition = 'start'::workspace_transition AND
workspace_builds.deadline IS NOT NULL AND
workspace_builds.deadline < $1 :: timestamptz
) OR
-- If the workspace build was a stop transition, the workspace is
-- potentially eligible for autostart if it has a schedule set. The
-- caller must check if the template allows autostart in a license-aware
-- fashion as we cannot check it here.
(
workspace_builds.transition = 'stop'::workspace_transition AND
workspaces.autostart_schedule IS NOT NULL
)
)
`
func (q *sqlQuerier) GetWorkspacesEligibleForAutoStartStop(ctx context.Context, now time.Time) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForAutoStartStop, now)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Workspace
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.OrganizationID,
&i.TemplateID,
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertWorkspace = `-- name: InsertWorkspace :one
INSERT INTO
workspaces (
+4 -2
View File
@@ -115,8 +115,10 @@ UPDATE
templates
SET
updated_at = $2,
default_ttl = $3,
max_ttl = $4
allow_user_autostart = $3,
allow_user_autostop = $4,
default_ttl = $5,
max_ttl = $6
WHERE
id = $1
RETURNING
+39
View File
@@ -392,3 +392,42 @@ SELECT
failed_workspaces.count AS failed_workspaces,
stopped_workspaces.count AS stopped_workspaces
FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces;
-- name: GetWorkspacesEligibleForAutoStartStop :many
SELECT
workspaces.*
FROM
workspaces
LEFT JOIN
workspace_builds ON workspace_builds.workspace_id = workspaces.id
WHERE
workspace_builds.build_number = (
SELECT
MAX(build_number)
FROM
workspace_builds
WHERE
workspace_builds.workspace_id = workspaces.id
) AND
(
-- If the workspace build was a start transition, the workspace is
-- potentially eligible for autostop if it's past the deadline. The
-- deadline is computed at build time upon success and is bumped based
-- on activity (up the max deadline if set). We don't need to check
-- license here since that's done when the values are written to the build.
(
workspace_builds.transition = 'start'::workspace_transition AND
workspace_builds.deadline IS NOT NULL AND
workspace_builds.deadline < @now :: timestamptz
) OR
-- If the workspace build was a stop transition, the workspace is
-- potentially eligible for autostart if it has a schedule set. The
-- caller must check if the template allows autostart in a license-aware
-- fashion as we cannot check it here.
(
workspace_builds.transition = 'stop'::workspace_transition AND
workspaces.autostart_schedule IS NOT NULL
)
);
@@ -986,9 +986,13 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete
if err != nil {
return xerrors.Errorf("get template schedule options: %w", err)
}
if !templateSchedule.UserSchedulingEnabled {
// The user is not permitted to set their own TTL.
if !templateSchedule.UserAutostopEnabled {
// The user is not permitted to set their own TTL, so use the
// template default.
deadline = time.Time{}
if templateSchedule.DefaultTTL > 0 {
deadline = now.Add(templateSchedule.DefaultTTL)
}
}
if templateSchedule.MaxTTL > 0 {
maxDeadline = now.Add(templateSchedule.MaxTTL)
@@ -828,11 +828,12 @@ func TestCompleteJob(t *testing.T) {
t.Parallel()
cases := []struct {
name string
templateDefaultTTL time.Duration
templateMaxTTL time.Duration
workspaceTTL time.Duration
transition database.WorkspaceTransition
name string
templateAllowAutostop bool
templateDefaultTTL time.Duration
templateMaxTTL time.Duration
workspaceTTL time.Duration
transition database.WorkspaceTransition
// The TTL is actually a deadline time on the workspace_build row,
// so during the test this will be compared to be within 15 seconds
// of the expected value.
@@ -840,76 +841,94 @@ func TestCompleteJob(t *testing.T) {
expectedMaxTTL time.Duration
}{
{
name: "OK",
templateDefaultTTL: 0,
templateMaxTTL: 0,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: 0,
expectedMaxTTL: 0,
name: "OK",
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateMaxTTL: 0,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: 0,
expectedMaxTTL: 0,
},
{
name: "Delete",
templateDefaultTTL: 0,
templateMaxTTL: 0,
workspaceTTL: 0,
transition: database.WorkspaceTransitionDelete,
expectedTTL: 0,
expectedMaxTTL: 0,
name: "Delete",
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateMaxTTL: 0,
workspaceTTL: 0,
transition: database.WorkspaceTransitionDelete,
expectedTTL: 0,
expectedMaxTTL: 0,
},
{
name: "WorkspaceTTL",
templateDefaultTTL: 0,
templateMaxTTL: 0,
workspaceTTL: time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: time.Hour,
expectedMaxTTL: 0,
name: "WorkspaceTTL",
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateMaxTTL: 0,
workspaceTTL: time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: time.Hour,
expectedMaxTTL: 0,
},
{
name: "TemplateDefaultTTLIgnored",
templateDefaultTTL: time.Hour,
templateMaxTTL: 0,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: 0,
expectedMaxTTL: 0,
name: "TemplateDefaultTTLIgnored",
templateAllowAutostop: true,
templateDefaultTTL: time.Hour,
templateMaxTTL: 0,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: 0,
expectedMaxTTL: 0,
},
{
name: "WorkspaceTTLOverridesTemplateDefaultTTL",
templateDefaultTTL: 2 * time.Hour,
templateMaxTTL: 0,
workspaceTTL: time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: time.Hour,
expectedMaxTTL: 0,
name: "WorkspaceTTLOverridesTemplateDefaultTTL",
templateAllowAutostop: true,
templateDefaultTTL: 2 * time.Hour,
templateMaxTTL: 0,
workspaceTTL: time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: time.Hour,
expectedMaxTTL: 0,
},
{
name: "TemplateMaxTTL",
templateDefaultTTL: 0,
templateMaxTTL: time.Hour,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: time.Hour,
expectedMaxTTL: time.Hour,
name: "TemplateMaxTTL",
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateMaxTTL: time.Hour,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: time.Hour,
expectedMaxTTL: time.Hour,
},
{
name: "TemplateMaxTTLOverridesWorkspaceTTL",
templateDefaultTTL: 0,
templateMaxTTL: 2 * time.Hour,
workspaceTTL: 3 * time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: 2 * time.Hour,
expectedMaxTTL: 2 * time.Hour,
name: "TemplateMaxTTLOverridesWorkspaceTTL",
templateAllowAutostop: true,
templateDefaultTTL: 0,
templateMaxTTL: 2 * time.Hour,
workspaceTTL: 3 * time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: 2 * time.Hour,
expectedMaxTTL: 2 * time.Hour,
},
{
name: "TemplateMaxTTLOverridesTemplateDefaultTTL",
templateDefaultTTL: 3 * time.Hour,
templateMaxTTL: 2 * time.Hour,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: 2 * time.Hour,
expectedMaxTTL: 2 * time.Hour,
name: "TemplateMaxTTLOverridesTemplateDefaultTTL",
templateAllowAutostop: true,
templateDefaultTTL: 3 * time.Hour,
templateMaxTTL: 2 * time.Hour,
workspaceTTL: 0,
transition: database.WorkspaceTransitionStart,
expectedTTL: 2 * time.Hour,
expectedMaxTTL: 2 * time.Hour,
},
{
name: "TemplateBlockWorkspaceTTL",
templateAllowAutostop: false,
templateDefaultTTL: 3 * time.Hour,
templateMaxTTL: 6 * time.Hour,
workspaceTTL: 4 * time.Hour,
transition: database.WorkspaceTransitionStart,
expectedTTL: 3 * time.Hour,
expectedMaxTTL: 6 * time.Hour,
},
}
@@ -921,12 +940,13 @@ func TestCompleteJob(t *testing.T) {
srv := setup(t, false)
var store schedule.TemplateScheduleStore = mockTemplateScheduleStore{
var store schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserSchedulingEnabled: true,
DefaultTTL: c.templateDefaultTTL,
MaxTTL: c.templateMaxTTL,
UserAutostartEnabled: false,
UserAutostopEnabled: c.templateAllowAutostop,
DefaultTTL: c.templateDefaultTTL,
MaxTTL: c.templateMaxTTL,
}, nil
},
}
@@ -938,10 +958,11 @@ func TestCompleteJob(t *testing.T) {
Provisioner: database.ProvisionerTypeEcho,
})
template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: template.ID,
UpdatedAt: database.Now(),
DefaultTTL: int64(c.templateDefaultTTL),
MaxTTL: int64(c.templateMaxTTL),
ID: template.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: c.templateAllowAutostop,
DefaultTTL: int64(c.templateDefaultTTL),
MaxTTL: int64(c.templateMaxTTL),
})
require.NoError(t, err)
file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID})
@@ -1190,17 +1211,3 @@ func must[T any](value T, err error) T {
}
return value
}
type mockTemplateScheduleStore struct {
GetFn func(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error)
}
var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{}
func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) {
return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, opts)
}
func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return m.GetFn(ctx, db, id)
}
+1 -1
View File
@@ -1,5 +1,5 @@
// package schedule provides utilities for managing template and workspace
// auto-start and auto-stop schedules. This includes utilities for parsing and
// autostart and autostop schedules. This includes utilities for parsing and
// deserializing cron-style expressions.
package schedule
+32
View File
@@ -0,0 +1,32 @@
package schedule
import (
"context"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
)
type MockTemplateScheduleStore struct {
GetFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error)
SetFn func(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error)
}
var _ TemplateScheduleStore = MockTemplateScheduleStore{}
func (m MockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) {
if m.GetFn != nil {
return m.GetFn(ctx, db, templateID)
}
return NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID)
}
func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) {
if m.SetFn != nil {
return m.SetFn(ctx, db, template, options)
}
return NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options)
}
+16 -5
View File
@@ -10,8 +10,9 @@ import (
)
type TemplateScheduleOptions struct {
UserSchedulingEnabled bool `json:"user_scheduling_enabled"`
DefaultTTL time.Duration `json:"default_ttl"`
UserAutostartEnabled bool `json:"user_autostart_enabled"`
UserAutostopEnabled bool `json:"user_autostop_enabled"`
DefaultTTL time.Duration `json:"default_ttl"`
// If MaxTTL is set, the workspace must be stopped before this time or it
// will be stopped automatically.
//
@@ -41,8 +42,11 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context
}
return TemplateScheduleOptions{
UserSchedulingEnabled: true,
DefaultTTL: time.Duration(tpl.DefaultTTL),
// Disregard the values in the database, since user scheduling is an
// enterprise feature.
UserAutostartEnabled: true,
UserAutostopEnabled: true,
DefaultTTL: time.Duration(tpl.DefaultTTL),
// Disregard the value in the database, since MaxTTL is an enterprise
// feature.
MaxTTL: 0,
@@ -50,12 +54,19 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context
}
func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
if int64(opts.DefaultTTL) == tpl.DefaultTTL {
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
return tpl, nil
}
return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: tpl.ID,
UpdatedAt: database.Now(),
DefaultTTL: int64(opts.DefaultTTL),
// Don't allow changing it, but keep the value in the DB (to avoid
// clearing settings if the license has an issue).
MaxTTL: tpl.MaxTTL,
AllowUserAutostart: tpl.AllowUserAutostart,
AllowUserAutostop: tpl.AllowUserAutostop,
MaxTTL: tpl.MaxTTL,
})
}
+31 -10
View File
@@ -227,10 +227,20 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
return
}
var allowUserCancelWorkspaceJobs bool
var (
allowUserCancelWorkspaceJobs bool
allowUserAutostart = true
allowUserAutostop = true
)
if createTemplate.AllowUserCancelWorkspaceJobs != nil {
allowUserCancelWorkspaceJobs = *createTemplate.AllowUserCancelWorkspaceJobs
}
if createTemplate.AllowUserAutostart != nil {
allowUserAutostart = *createTemplate.AllowUserAutostart
}
if createTemplate.AllowUserAutostop != nil {
allowUserAutostop = *createTemplate.AllowUserAutostop
}
var dbTemplate database.Template
var template codersdk.Template
@@ -259,9 +269,10 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
}
dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{
UserSchedulingEnabled: true,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
UserAutostartEnabled: allowUserAutostart,
UserAutostopEnabled: allowUserAutostop,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
})
if err != nil {
return xerrors.Errorf("set template schedule options: %s", err)
@@ -478,6 +489,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
req.Description == template.Description &&
req.DisplayName == template.DisplayName &&
req.Icon == template.Icon &&
req.AllowUserAutostart == template.AllowUserAutostart &&
req.AllowUserAutostop == template.AllowUserAutostop &&
req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs &&
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() &&
req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() {
@@ -491,7 +504,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
displayName := req.DisplayName
desc := req.Description
icon := req.Icon
allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs
if name == "" {
name = template.Name
@@ -508,7 +520,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
DisplayName: displayName,
Description: desc,
Icon: icon,
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs,
})
if err != nil {
return xerrors.Errorf("update template metadata: %w", err)
@@ -516,11 +528,18 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond
maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond
if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) {
if defaultTTL != time.Duration(template.DefaultTTL) ||
maxTTL != time.Duration(template.MaxTTL) ||
req.AllowUserAutostart != template.AllowUserAutostart ||
req.AllowUserAutostop != template.AllowUserAutostop {
updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{
UserSchedulingEnabled: true,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
// Some of these values are enterprise-only, but the
// TemplateScheduleStore will handle avoiding setting them if
// unlicensed.
UserAutostartEnabled: req.AllowUserAutostart,
UserAutostopEnabled: req.AllowUserAutostop,
DefaultTTL: defaultTTL,
MaxTTL: maxTTL,
})
if err != nil {
return xerrors.Errorf("set template schedule options: %w", err)
@@ -661,6 +680,8 @@ func (api *API) convertTemplate(
MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(),
CreatedByID: template.CreatedBy,
CreatedByName: createdByName,
AllowUserAutostart: template.AllowUserAutostart,
AllowUserAutostop: template.AllowUserAutostop,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
}
}
+161 -8
View File
@@ -157,8 +157,8 @@ func TestPostTemplateByOrganization(t *testing.T) {
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: mockTemplateScheduleStore{
setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
atomic.AddInt64(&setCalled, 1)
require.Equal(t, maxTTL, options.MaxTTL)
template.DefaultTTL = int64(options.DefaultTTL)
@@ -233,6 +233,67 @@ func TestPostTemplateByOrganization(t *testing.T) {
})
})
t.Run("AllowUserScheduling", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
atomic.AddInt64(&setCalled, 1)
require.False(t, options.UserAutostartEnabled)
require.False(t, options.UserAutostopEnabled)
template.AllowUserAutostart = options.UserAutostartEnabled
template.AllowUserAutostop = options.UserAutostopEnabled
return template, nil
},
},
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{
Name: "testing",
VersionID: version.ID,
AllowUserAutostart: ptr.Ref(false),
AllowUserAutostop: ptr.Ref(false),
})
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.False(t, got.AllowUserAutostart)
require.False(t, got.AllowUserAutostop)
})
t.Run("IgnoredUnlicensed", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{
Name: "testing",
VersionID: version.ID,
AllowUserAutostart: ptr.Ref(false),
AllowUserAutostop: ptr.Ref(false),
})
require.NoError(t, err)
// ignored and use AGPL defaults
require.True(t, got.AllowUserAutostart)
require.True(t, got.AllowUserAutostop)
})
})
t.Run("NoVersion", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
@@ -448,8 +509,8 @@ func TestPatchTemplateMeta(t *testing.T) {
var setCalled int64
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: mockTemplateScheduleStore{
setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
if atomic.AddInt64(&setCalled, 1) == 2 {
require.Equal(t, maxTTL, options.MaxTTL)
}
@@ -543,6 +604,96 @@ func TestPatchTemplateMeta(t *testing.T) {
})
})
t.Run("AllowUserScheduling", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
var (
setCalled int64
allowAutostart atomic.Bool
allowAutostop atomic.Bool
)
allowAutostart.Store(true)
allowAutostop.Store(true)
client := coderdtest.New(t, &coderdtest.Options{
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
atomic.AddInt64(&setCalled, 1)
assert.Equal(t, allowAutostart.Load(), options.UserAutostartEnabled)
assert.Equal(t, allowAutostop.Load(), options.UserAutostopEnabled)
template.DefaultTTL = int64(options.DefaultTTL)
template.MaxTTL = int64(options.MaxTTL)
template.AllowUserAutostart = options.UserAutostartEnabled
template.AllowUserAutostop = options.UserAutostopEnabled
return template, nil
},
},
})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
})
require.Equal(t, allowAutostart.Load(), template.AllowUserAutostart)
require.Equal(t, allowAutostop.Load(), template.AllowUserAutostop)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
allowAutostart.Store(false)
allowAutostop.Store(false)
got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
MaxTTLMillis: template.MaxTTLMillis,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: allowAutostart.Load(),
AllowUserAutostop: allowAutostop.Load(),
})
require.NoError(t, err)
require.EqualValues(t, 2, atomic.LoadInt64(&setCalled))
require.Equal(t, allowAutostart.Load(), got.AllowUserAutostart)
require.Equal(t, allowAutostop.Load(), got.AllowUserAutostop)
})
t.Run("IgnoredUnlicensed", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds())
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
Description: template.Description,
Icon: template.Icon,
// Increase the default TTL to avoid error "not modified".
DefaultTTLMillis: template.DefaultTTLMillis + 1,
MaxTTLMillis: template.MaxTTLMillis,
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
AllowUserAutostart: false,
AllowUserAutostop: false,
})
require.NoError(t, err)
require.True(t, got.AllowUserAutostart)
require.True(t, got.AllowUserAutostop)
})
})
t.Run("NotModified", func(t *testing.T) {
t.Parallel()
@@ -559,10 +710,12 @@ func TestPatchTemplateMeta(t *testing.T) {
defer cancel()
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
Name: template.Name,
Description: template.Description,
Icon: template.Icon,
DefaultTTLMillis: template.DefaultTTLMillis,
AllowUserAutostart: template.AllowUserAutostart,
AllowUserAutostop: template.AllowUserAutostop,
}
_, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.ErrorContains(t, err, "not modified")
+21 -1
View File
@@ -735,6 +735,23 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
return
}
// Check if the template allows users to configure autostart.
templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, workspace.TemplateID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting template schedule options.",
Detail: err.Error(),
})
return
}
if !templateSchedule.UserAutostartEnabled {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Autostart is not allowed for workspaces using this template.",
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: "Autostart is not allowed for workspaces using this template."}},
})
return
}
err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
ID: workspace.ID,
AutostartSchedule: dbSched,
@@ -790,9 +807,12 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
if err != nil {
return xerrors.Errorf("get template schedule: %w", err)
}
if !templateSchedule.UserAutostopEnabled {
return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."}
}
// don't override 0 ttl with template default here because it indicates
// disabled auto-stop
// disabled autostop
var validityErr error
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL)
if validityErr != nil {
+94
View File
@@ -1271,7 +1271,54 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
})
}
t.Run("CustomAutostartDisabledByTemplate", func(t *testing.T) {
t.Parallel()
var (
tss = schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
}, nil
},
SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) {
return tpl, nil
},
}
client = coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
TemplateScheduleStore: tss,
})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = nil
cwr.TTLMillis = nil
})
)
// await job to ensure audit logs for workspace_build start are created
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// ensure test invariant: new workspaces have no autostart schedule.
require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
Schedule: ptr.Ref("CRON_TZ=Europe/Dublin 30 9 * * 1-5"),
})
require.ErrorContains(t, err, "Autostart is not allowed for workspaces using this template")
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
@@ -1391,7 +1438,54 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
})
}
t.Run("CustomAutostopDisabledByTemplate", func(t *testing.T) {
t.Parallel()
var (
tss = schedule.MockTemplateScheduleStore{
GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
UserAutostartEnabled: false,
UserAutostopEnabled: false,
DefaultTTL: 0,
MaxTTL: 0,
}, nil
},
SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) {
return tpl, nil
},
}
client = coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
TemplateScheduleStore: tss,
})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutostartSchedule = nil
cwr.TTLMillis = nil
})
)
// await job to ensure audit logs for workspace_build start are created
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// ensure test invariant: new workspaces have no autostart schedule.
require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule")
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTLMillis: ptr.Ref(time.Hour.Milliseconds()),
})
require.ErrorContains(t, err, "Custom autostop TTL is not allowed for workspaces using this template")
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
+11
View File
@@ -95,6 +95,17 @@ type CreateTemplateRequest struct {
// Allow users to cancel in-progress workspace jobs.
// *bool as the default value is "true".
AllowUserCancelWorkspaceJobs *bool `json:"allow_user_cancel_workspace_jobs"`
// AllowUserAutostart allows users to set a schedule for autostarting their
// workspace. By default this is true. This can only be disabled when using
// an enterprise license.
AllowUserAutostart *bool `json:"allow_user_autostart"`
// AllowUserAutostop allows users to set a custom workspace TTL to use in
// place of the template's DefaultTTL field. By default this is true. If
// false, the DefaultTTL will always be used. This can only be disabled when
// using an enterprise license.
AllowUserAutostop *bool `json:"allow_user_autostop"`
}
// CreateWorkspaceRequest provides options for creating a new workspace.
+7
View File
@@ -34,6 +34,11 @@ type Template struct {
CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"`
CreatedByName string `json:"created_by_name"`
// AllowUserAutostart and AllowUserAutostop are enterprise-only. Their
// values are only used if your license is entitled to use the advanced
// template scheduling feature.
AllowUserAutostart bool `json:"allow_user_autostart"`
AllowUserAutostop bool `json:"allow_user_autostop"`
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"`
}
@@ -87,6 +92,8 @@ type UpdateTemplateMeta struct {
// template scheduling feature. If you attempt to set this value while
// unlicensed, it will be ignored.
MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"`
AllowUserAutostart bool `json:"allow_user_autostart,omitempty"`
AllowUserAutostop bool `json:"allow_user_autostop,omitempty"`
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
}
+11 -11
View File
@@ -9,17 +9,17 @@ We track the following resources:
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
| <b>Resource<b> | |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| <b>Resource<b> | |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/audit-logs.md'. -->
+38 -30
View File
@@ -1271,6 +1271,8 @@ CreateParameterRequest is a structure used to create a new parameter value for a
```json
{
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"default_ttl_ms": 0,
"description": "string",
@@ -1293,17 +1295,19 @@ CreateParameterRequest is a structure used to create a new parameter value for a
### Properties
| Name | Type | Required | Restrictions | Description |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------- |
| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". |
| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. |
| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. |
| `display_name` | string | false | | Display name is the displayed name of the template. |
| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. |
| `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. |
| `name` | string | true | | Name is the name of the template. |
| `parameter_values` | array of [codersdk.CreateParameterRequest](#codersdkcreateparameterrequest) | false | | Parameter values is a structure used to create a new parameter value for a scope.] |
| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. |
| Name | Type | Required | Restrictions | Description |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. |
| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. |
| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". |
| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. |
| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. |
| `display_name` | string | false | | Display name is the displayed name of the template. |
| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. |
| `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. |
| `name` | string | true | | Name is the name of the template. |
| `parameter_values` | array of [codersdk.CreateParameterRequest](#codersdkcreateparameterrequest) | false | | Parameter values is a structure used to create a new parameter value for a scope.] |
| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. |
| This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users. |
## codersdk.CreateTemplateVersionDryRunRequest
@@ -3618,6 +3622,8 @@ Parameter represents a set value for the scope.
{
"active_user_count": 0,
"active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc",
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"build_time_stats": {
"property1": {
@@ -3647,25 +3653,27 @@ Parameter represents a set value for the scope.
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `active_user_count` | integer | false | | Active user count is set to -1 when loading. |
| `active_version_id` | string | false | | |
| `allow_user_cancel_workspace_jobs` | boolean | false | | |
| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | |
| `created_at` | string | false | | |
| `created_by_id` | string | false | | |
| `created_by_name` | string | false | | |
| `default_ttl_ms` | integer | false | | |
| `description` | string | false | | |
| `display_name` | string | false | | |
| `icon` | string | false | | |
| `id` | string | false | | |
| `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. |
| `name` | string | false | | |
| `organization_id` | string | false | | |
| `provisioner` | string | false | | |
| `updated_at` | string | false | | |
| Name | Type | Required | Restrictions | Description |
| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active_user_count` | integer | false | | Active user count is set to -1 when loading. |
| `active_version_id` | string | false | | |
| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. |
| `allow_user_autostop` | boolean | false | | |
| `allow_user_cancel_workspace_jobs` | boolean | false | | |
| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | |
| `created_at` | string | false | | |
| `created_by_id` | string | false | | |
| `created_by_name` | string | false | | |
| `default_ttl_ms` | integer | false | | |
| `description` | string | false | | |
| `display_name` | string | false | | |
| `icon` | string | false | | |
| `id` | string | false | | |
| `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. |
| `name` | string | false | | |
| `organization_id` | string | false | | |
| `provisioner` | string | false | | |
| `updated_at` | string | false | | |
#### Enumerated Values
+37 -23
View File
@@ -99,6 +99,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
{
"active_user_count": 0,
"active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc",
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"build_time_stats": {
"property1": {
@@ -137,29 +139,31 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| ------------------------------------ | ---------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `[array item]` | array | false | | |
| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. |
| `» active_version_id` | string(uuid) | false | | |
| `» allow_user_cancel_workspace_jobs` | boolean | false | | |
| build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | |
| » [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | |
| »» p50` | integer | false | | |
| `»»» p95` | integer | false | | |
| created_at` | string(date-time) | false | | |
| created_by_id` | string(uuid) | false | | |
| `» created_by_name` | string | false | | |
| default_ttl_ms` | integer | false | | |
| description` | string | false | | |
| `» display_name` | string | false | | |
| icon` | string | false | | |
| id` | string(uuid) | false | | |
| max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. |
| name` | string | false | | |
| organization_id` | string(uuid) | false | | |
| provisioner` | string | false | | |
| updated_at` | string(date-time) | false | | |
| Name | Type | Required | Restrictions | Description |
| ------------------------------------ | ---------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `[array item]` | array | false | | |
| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. |
| `» active_version_id` | string(uuid) | false | | |
| `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. |
| allow_user_autostop` | boolean | false | | |
| allow_user_cancel_workspace_jobs` | boolean | false | | |
| build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | |
| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | |
| »» p50` | integer | false | | |
| »» p95` | integer | false | | |
| `» created_at` | string(date-time) | false | | |
| created_by_id` | string(uuid) | false | | |
| created_by_name` | string | false | | |
| `» default_ttl_ms` | integer | false | | |
| description` | string | false | | |
| display_name` | string | false | | |
| icon` | string | false | | |
| id` | string(uuid) | false | | |
| max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. |
| name` | string | false | | |
| organization_id` | string(uuid) | false | | |
| `» provisioner` | string | false | | |
| `» updated_at` | string(date-time) | false | | |
#### Enumerated Values
@@ -187,6 +191,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
```json
{
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"default_ttl_ms": 0,
"description": "string",
@@ -222,6 +228,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
{
"active_user_count": 0,
"active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc",
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"build_time_stats": {
"property1": {
@@ -345,6 +353,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
{
"active_user_count": 0,
"active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc",
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"build_time_stats": {
"property1": {
@@ -670,6 +680,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \
{
"active_user_count": 0,
"active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc",
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"build_time_stats": {
"property1": {
@@ -776,6 +788,8 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \
{
"active_user_count": 0,
"active_version_id": "eae64611-bd53-4a80-bb77-df1e432c0fbc",
"allow_user_autostart": true,
"allow_user_autostop": true,
"allow_user_cancel_workspace_jobs": true,
"build_time_stats": {
"property1": {
+18
View File
@@ -12,6 +12,24 @@ coder templates edit [flags] <template>
## Options
### --allow-user-autostart
| | |
| ------- | ----------------- |
| Type | <code>bool</code> |
| Default | <code>true</code> |
Allow users to configure autostart for workspaces on this template. This can only be disabled in enterprise.
### --allow-user-autostop
| | |
| ------- | ----------------- |
| Type | <code>bool</code> |
| Default | <code>true</code> |
Allow users to customize the autostop TTL for workspaces on this template. This can only be disabled in enterprise.
### --allow-user-cancel-workspace-jobs
| | |
+12 -12
View File
@@ -4,18 +4,18 @@ Coder is free to use and includes some features that are only accessible with a
[Contact Sales](https://coder.com/contact) for pricing or [get a free
trial](https://coder.com/trial).
| Category | Feature | Open Source | Enterprise |
| --------------- | --------------------------------------------------------------------------- | :---------: | :--------: |
| User Management | [Groups](./admin/groups.md) | ❌ | ✅ |
| User Management | [SCIM](./admin/auth.md#scim) | ❌ | ✅ |
| Governance | [Audit Logging](./admin/audit-logs.md) | ❌ | ✅ |
| Governance | [Browser Only Connections](./networking.md#browser-only-connections) | ❌ | ✅ |
| Governance | [Template Access Control](./admin/rbac.md) | ❌ | ✅ |
| Cost Control | [Quotas](./admin/quotas.md) | ❌ | ✅ |
| Cost Control | [Max Workspace Auto-Stop](./templates.md#configure-max-workspace-auto-stop) | ❌ | ✅ |
| Deployment | [High Availability](./admin/high-availability.md) | ❌ | ✅ |
| Deployment | [Service Banners](./admin/service-banners.md) | ❌ | ✅ |
| Deployment | Isolated Terraform Runners | ❌ | ✅ |
| Category | Feature | Open Source | Enterprise |
| --------------- | ------------------------------------------------------------------------- | :---------: | :--------: |
| User Management | [Groups](./admin/groups.md) | ❌ | ✅ |
| User Management | [SCIM](./admin/auth.md#scim) | ❌ | ✅ |
| Governance | [Audit Logging](./admin/audit-logs.md) | ❌ | ✅ |
| Governance | [Browser Only Connections](./networking.md#browser-only-connections) | ❌ | ✅ |
| Governance | [Template Access Control](./admin/rbac.md) | ❌ | ✅ |
| Cost Control | [Quotas](./admin/quotas.md) | ❌ | ✅ |
| Cost Control | [Max Workspace Autostop](./templates.md#configure-max-workspace-autostop) | ❌ | ✅ |
| Deployment | [High Availability](./admin/high-availability.md) | ❌ | ✅ |
| Deployment | [Service Banners](./admin/service-banners.md) | ❌ | ✅ |
| Deployment | Isolated Terraform Runners | ❌ | ✅ |
> Previous plans to restrict OIDC and Git Auth features in OSS have been removed
> as of 2023-01-11

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

+1 -1
View File
@@ -70,7 +70,7 @@ coder templates create <template-name>
> [examples/](https://github.com/coder/coder/tree/main/examples/templates)
> directory in the repo.
## Configure Max Workspace Auto-Stop
## Configure Max Workspace Autostop
To control cost, specify a maximum time to live flag for a template in hours or
minutes.
+7 -7
View File
@@ -46,20 +46,20 @@ can be defined on a per-workspace basis to automate the workspace start/stop.
![Scheduling UI](./images/schedule.png)
### Auto-start
### Autostart
The auto-start feature automates the workspace build at a user-specified time
The autostart feature automates the workspace build at a user-specified time
and day(s) of the week. In addition, users can select their preferred timezone.
![Auto-start UI](./images/auto-start.png)
![Autostart UI](./images/autostart.png)
### Auto-stop
### Autostop
The auto-stop feature shuts off workspaces after given number of hours in the "on"
state. If Coder detects workspace connection activity, the auto-stop timer is bumped up
The autostop feature shuts off workspaces after given number of hours in the "on"
state. If Coder detects workspace connection activity, the autostop timer is bumped up
one hour. IDE, SSH, Port Forwarding, and coder_app activity trigger this bump.
![auto-stop UI](./images/auto-stop.png)
![autostop UI](./images/autostop.png)
## Updating workspaces
+2
View File
@@ -71,6 +71,8 @@ var auditableResourcesTypes = map[any]map[string]Action{
"created_by": ActionTrack,
"group_acl": ActionTrack,
"user_acl": ActionTrack,
"allow_user_autostart": ActionTrack,
"allow_user_autostop": ActionTrack,
"allow_user_cancel_workspace_jobs": ActionTrack,
"max_ttl": ActionTrack,
},
+19 -9
View File
@@ -227,7 +227,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request)
Provisioners: daemon.Provisioners,
Telemetry: api.Telemetry,
Auditor: &api.AGPL.Auditor,
TemplateScheduleStore: &api.AGPL.TemplateScheduleStore,
TemplateScheduleStore: api.AGPL.TemplateScheduleStore,
Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)),
Tags: rawTags,
})
@@ -318,19 +318,29 @@ func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C
}
return schedule.TemplateScheduleOptions{
// TODO: make configurable at template level
UserSchedulingEnabled: true,
DefaultTTL: time.Duration(tpl.DefaultTTL),
MaxTTL: time.Duration(tpl.MaxTTL),
UserAutostartEnabled: tpl.AllowUserAutostart,
UserAutostopEnabled: tpl.AllowUserAutostop,
DefaultTTL: time.Duration(tpl.DefaultTTL),
MaxTTL: time.Duration(tpl.MaxTTL),
}, nil
}
func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) {
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
int64(opts.MaxTTL) == tpl.MaxTTL &&
opts.UserAutostartEnabled == tpl.AllowUserAutostart &&
opts.UserAutostopEnabled == tpl.AllowUserAutostop {
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
return tpl, nil
}
template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: tpl.ID,
UpdatedAt: database.Now(),
DefaultTTL: int64(opts.DefaultTTL),
MaxTTL: int64(opts.MaxTTL),
ID: tpl.ID,
UpdatedAt: database.Now(),
AllowUserAutostart: opts.UserAutostartEnabled,
AllowUserAutostop: opts.UserAutostopEnabled,
DefaultTTL: int64(opts.DefaultTTL),
MaxTTL: int64(opts.MaxTTL),
})
if err != nil {
return database.Template{}, xerrors.Errorf("update template schedule: %w", err)
+6
View File
@@ -190,6 +190,8 @@ export interface CreateTemplateRequest {
readonly default_ttl_ms?: number
readonly max_ttl_ms?: number
readonly allow_user_cancel_workspace_jobs?: boolean
readonly allow_user_autostart?: boolean
readonly allow_user_autostop?: boolean
}
// From codersdk/templateversions.go
@@ -785,6 +787,8 @@ export interface Template {
readonly max_ttl_ms: number
readonly created_by_id: string
readonly created_by_name: string
readonly allow_user_autostart: boolean
readonly allow_user_autostop: boolean
readonly allow_user_cancel_workspace_jobs: boolean
}
@@ -952,6 +956,8 @@ export interface UpdateTemplateMeta {
readonly icon?: string
readonly default_ttl_ms?: number
readonly max_ttl_ms?: number
readonly allow_user_autostart?: boolean
readonly allow_user_autostop?: boolean
readonly allow_user_cancel_workspace_jobs?: boolean
}
@@ -11,8 +11,8 @@ import { Link as RouterLink } from "react-router-dom"
import { Workspace } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import {
autoStartDisplay,
autoStopDisplay,
autostartDisplay,
autostopDisplay,
extractTimezone,
Language as ScheduleLanguage,
} from "../../util/schedule"
@@ -53,19 +53,19 @@ export const WorkspaceSchedule: FC<
</div>
<div>
<span className={styles.scheduleLabel}>
{ScheduleLanguage.autoStartLabel}
{ScheduleLanguage.autostartLabel}
</span>
<span className={styles.scheduleValue}>
{autoStartDisplay(workspace.autostart_schedule)}
{autostartDisplay(workspace.autostart_schedule)}
</span>
</div>
<div>
<span className={styles.scheduleLabel}>
{ScheduleLanguage.autoStopLabel}
{ScheduleLanguage.autostopLabel}
</span>
<Stack direction="row">
<span className={styles.scheduleValue}>
{autoStopDisplay(workspace)}
{autostopDisplay(workspace)}
</span>
</Stack>
</div>
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next"
import { Workspace } from "../../api/typesGenerated"
import { combineClasses } from "../../util/combineClasses"
import {
autoStartDisplay,
autoStopDisplay,
autostartDisplay,
autostopDisplay,
isShuttingDown,
} from "../../util/schedule"
import { isWorkspaceOn } from "../../util/workspace"
@@ -24,18 +24,18 @@ export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({
className={combineClasses([styles.labelText, "chromatic-ignore"])}
>
<Maybe condition={!isShuttingDown(workspace)}>
<strong>{t("schedule.autoStopLabel")}</strong>
<strong>{t("schedule.autostopLabel")}</strong>
</Maybe>{" "}
<span className={styles.value}>{autoStopDisplay(workspace)}</span>
<span className={styles.value}>{autostopDisplay(workspace)}</span>
</span>
</Cond>
<Cond>
<span
className={combineClasses([styles.labelText, "chromatic-ignore"])}
>
<strong>{t("schedule.autoStartLabel")}</strong>{" "}
<strong>{t("schedule.autostartLabel")}</strong>{" "}
<span className={styles.value}>
{autoStartDisplay(workspace.autostart_schedule)}
{autostartDisplay(workspace.autostart_schedule)}
</span>
</span>
</Cond>
@@ -36,28 +36,28 @@ const Template: Story<WorkspaceScheduleFormProps> = (args) => (
)
const defaultInitialValues = {
autoStartEnabled: true,
autostartEnabled: true,
...defaultSchedule(),
autoStopEnabled: true,
autostopEnabled: true,
ttl: 24,
}
export const AllDisabled = Template.bind({})
AllDisabled.args = {
initialValues: {
autoStartEnabled: false,
autostartEnabled: false,
...emptySchedule,
autoStopEnabled: false,
autostopEnabled: false,
ttl: emptyTTL,
},
}
export const AutoStart = Template.bind({})
AutoStart.args = {
export const Autostart = Template.bind({})
Autostart.args = {
initialValues: {
autoStartEnabled: true,
autostartEnabled: true,
...defaultSchedule(),
autoStopEnabled: false,
autostopEnabled: false,
ttl: emptyTTL,
},
}
@@ -7,7 +7,7 @@ import {
import { zones } from "./zones"
const valid: WorkspaceScheduleFormValues = {
autoStartEnabled: true,
autostartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -18,14 +18,14 @@ const valid: WorkspaceScheduleFormValues = {
startTime: "09:30",
timezone: "Canada/Eastern",
autoStopEnabled: true,
autostopEnabled: true,
ttl: 120,
}
describe("validationSchema", () => {
it("allows everything to be falsy when switches are off", () => {
const values: WorkspaceScheduleFormValues = {
autoStartEnabled: false,
autostartEnabled: false,
sunday: false,
monday: false,
tuesday: false,
@@ -36,7 +36,7 @@ describe("validationSchema", () => {
startTime: "",
timezone: "",
autoStopEnabled: false,
autostopEnabled: false,
ttl: 0,
}
const validate = () => validationSchema.validateSync(values)
@@ -52,7 +52,7 @@ describe("validationSchema", () => {
expect(validate).toThrow()
})
it("disallows all days-of-week to be false when auto-start is enabled", () => {
it("disallows all days-of-week to be false when autostart is enabled", () => {
const values: WorkspaceScheduleFormValues = {
...valid,
sunday: false,
@@ -67,7 +67,7 @@ describe("validationSchema", () => {
expect(validate).toThrowError(Language.errorNoDayOfWeek)
})
it("disallows empty startTime when auto-start is enabled", () => {
it("disallows empty startTime when autostart is enabled", () => {
const values: WorkspaceScheduleFormValues = {
...valid,
sunday: false,
@@ -39,12 +39,12 @@ dayjs.extend(timezone)
export const Language = {
errorNoDayOfWeek:
"Must set at least one day of week if auto-start is enabled.",
errorNoTime: "Start time is required when auto-start is enabled.",
"Must set at least one day of week if autostart is enabled.",
errorNoTime: "Start time is required when autostart is enabled.",
errorTime: "Time must be in HH:mm format (24 hours).",
errorTimezone: "Invalid timezone.",
errorNoStop:
"Time until shutdown must be greater than zero when auto-stop is enabled.",
"Time until shutdown must be greater than zero when autostop is enabled.",
errorTtlMax:
"Please enter a limit that is less than or equal to 168 hours (7 days).",
daysOfWeekLabel: "Days of Week",
@@ -67,9 +67,9 @@ export const Language = {
"Your workspace will not automatically shut down.",
formTitle: "Workspace schedule",
startSection: "Start",
startSwitch: "Auto-start",
startSwitch: "Autostart",
stopSection: "Stop",
stopSwitch: "Auto-stop",
stopSwitch: "Autostop",
}
export interface WorkspaceScheduleFormProps {
@@ -84,7 +84,7 @@ export interface WorkspaceScheduleFormProps {
}
export interface WorkspaceScheduleFormValues {
autoStartEnabled: boolean
autostartEnabled: boolean
sunday: boolean
monday: boolean
tuesday: boolean
@@ -95,7 +95,7 @@ export interface WorkspaceScheduleFormValues {
startTime: string
timezone: string
autoStopEnabled: boolean
autostopEnabled: boolean
ttl: number
}
@@ -107,7 +107,7 @@ export const validationSchema = Yup.object({
function (value) {
const parent = this.parent as WorkspaceScheduleFormValues
if (!parent.autoStartEnabled) {
if (!parent.autostartEnabled) {
return true
} else {
return ![
@@ -130,9 +130,9 @@ export const validationSchema = Yup.object({
startTime: Yup.string()
.ensure()
.test("required-if-auto-start", Language.errorNoTime, function (value) {
.test("required-if-autostart", Language.errorNoTime, function (value) {
const parent = this.parent as WorkspaceScheduleFormValues
if (parent.autoStartEnabled) {
if (parent.autostartEnabled) {
return value !== ""
} else {
return true
@@ -173,9 +173,9 @@ export const validationSchema = Yup.object({
.integer()
.min(0)
.max(24 * 7 /* 7 days */, Language.errorTtlMax)
.test("positive-if-auto-stop", Language.errorNoStop, function (value) {
.test("positive-if-autostop", Language.errorNoStop, function (value) {
const parent = this.parent as WorkspaceScheduleFormValues
if (parent.autoStopEnabled) {
if (parent.autostopEnabled) {
return Boolean(value)
} else {
return true
@@ -245,35 +245,35 @@ export const WorkspaceScheduleForm: FC<
},
]
const handleToggleAutoStart = async (e: ChangeEvent) => {
const handleToggleAutostart = async (e: ChangeEvent) => {
form.handleChange(e)
if (form.values.autoStartEnabled) {
if (form.values.autostartEnabled) {
// disable autostart, clear values
await form.setValues({
...form.values,
autoStartEnabled: false,
autostartEnabled: false,
...emptySchedule,
})
} else {
// enable autostart, fill with defaults
await form.setValues({
...form.values,
autoStartEnabled: true,
autostartEnabled: true,
...defaultSchedule(),
})
}
}
const handleToggleAutoStop = async (e: ChangeEvent) => {
const handleToggleAutostop = async (e: ChangeEvent) => {
form.handleChange(e)
if (form.values.autoStopEnabled) {
if (form.values.autostopEnabled) {
// disable autostop, set TTL 0
await form.setValues({ ...form.values, autoStopEnabled: false, ttl: 0 })
await form.setValues({ ...form.values, autostopEnabled: false, ttl: 0 })
} else {
// enable autostop, fill with default TTL
await form.setValues({
...form.values,
autoStopEnabled: true,
autostopEnabled: true,
ttl: defaultTTL,
})
}
@@ -290,9 +290,9 @@ export const WorkspaceScheduleForm: FC<
<FormControlLabel
control={
<Switch
name="autoStartEnabled"
checked={form.values.autoStartEnabled}
onChange={handleToggleAutoStart}
name="autostartEnabled"
checked={form.values.autostartEnabled}
onChange={handleToggleAutostart}
color="primary"
/>
}
@@ -301,11 +301,11 @@ export const WorkspaceScheduleForm: FC<
<TextField
{...formHelpers(
"startTime",
form.values.autoStartEnabled
form.values.autostartEnabled
? Language.startTimeHelperText
: Language.noStartTimeHelperText,
)}
disabled={isLoading || !form.values.autoStartEnabled}
disabled={isLoading || !form.values.autostartEnabled}
InputLabelProps={{
shrink: true,
}}
@@ -316,7 +316,7 @@ export const WorkspaceScheduleForm: FC<
<TextField
{...formHelpers("timezone")}
disabled={isLoading || !form.values.autoStartEnabled}
disabled={isLoading || !form.values.autostartEnabled}
InputLabelProps={{
shrink: true,
}}
@@ -345,7 +345,7 @@ export const WorkspaceScheduleForm: FC<
control={
<Checkbox
checked={checkbox.value}
disabled={isLoading || !form.values.autoStartEnabled}
disabled={isLoading || !form.values.autostartEnabled}
onChange={form.handleChange}
name={checkbox.name}
color="primary"
@@ -369,9 +369,9 @@ export const WorkspaceScheduleForm: FC<
<FormControlLabel
control={
<Switch
name="autoStopEnabled"
checked={form.values.autoStopEnabled}
onChange={handleToggleAutoStop}
name="autostopEnabled"
checked={form.values.autostopEnabled}
onChange={handleToggleAutostop}
color="primary"
/>
}
@@ -379,7 +379,7 @@ export const WorkspaceScheduleForm: FC<
/>
<TextField
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
disabled={isLoading || !form.values.autoStopEnabled}
disabled={isLoading || !form.values.autostopEnabled}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
type="number"
+2 -2
View File
@@ -21,8 +21,8 @@
"incorrectName": "Incorrect {{entity}} name."
},
"schedule": {
"autoStartLabel": "Starts at",
"autoStopLabel": "Stops at"
"autostartLabel": "Starts at",
"autostopLabel": "Stops at"
},
"ctas": {
"dismissCta": "Dismiss",
+3 -3
View File
@@ -26,7 +26,7 @@
"displayName": "Display name",
"description": "Description",
"icon": "Icon",
"autoStop": "Default auto-stop",
"autostop": "Default autostop",
"maxTTL": "Max. Lifetime (alpha)",
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
},
@@ -49,9 +49,9 @@
"error": {
"descriptionMax": "Please enter a description that is less than or equal to 128 characters.",
"defaultTTLMax": "Please enter a limit that is less than or equal to 168 hours (7 days).",
"defaultTTLMin": "Default time until auto-stop must not be less than 0.",
"defaultTTLMin": "Default time until autostop must not be less than 0.",
"maxTTLMax": "Please enter a limit that is less than or equal to 168 hours (7 days).",
"maxTTLMin": "Maximum time until auto-stop must not be less than 0."
"maxTTLMin": "Maximum time until autostop must not be less than 0."
}
}
}
+2 -2
View File
@@ -24,11 +24,11 @@
"displayName": "Display name",
"description": "Description",
"icon": "Icon",
"autoStop": "Auto-stop default",
"autostop": "Autostop default",
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
},
"helperText": {
"autoStop": "Time in hours",
"autostop": "Time in hours",
"allowUsersToCancel": "If checked, users may be able to corrupt their workspace."
},
"upload": {
+3 -3
View File
@@ -4,18 +4,18 @@
"displayNameLabel": "Display name",
"descriptionLabel": "Description",
"descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.",
"defaultTtlLabel": "Default auto-stop",
"defaultTtlLabel": "Default autostop",
"maxTtlLabel": "Max. Lifetime (alpha)",
"iconLabel": "Icon",
"formAriaLabel": "Template settings form",
"selectEmoji": "Select emoji",
"defaultTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
"defaultTTLMinError": "Default time until auto-stop must not be less than 0.",
"defaultTTLMinError": "Default time until autostop must not be less than 0.",
"defaultTTLHelperText_zero": "Workspaces will run until stopped manually.",
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour.",
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours.",
"maxTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).",
"maxTTLMinError": "Maximum time until auto-stop must not be less than 0.",
"maxTTLMinError": "Maximum time until autostop must not be less than 0.",
"maxTTLHelperText_zero": "Workspaces may run indefinitely.",
"maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.",
"maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"forbiddenError": "You don't have permissions to update the schedule for this workspace.",
"dialogTitle": "Restart workspace?",
"dialogDescription": "Would you like to restart your workspace now to apply your new auto-stop setting, or let it apply after your next workspace start?",
"dialogDescription": "Would you like to restart your workspace now to apply your new autostop setting, or let it apply after your next workspace start?",
"restart": "Restart workspace now",
"applyLater": "Apply update later"
}
@@ -311,7 +311,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
label={t("form.fields.autoStop")}
label={t("form.fields.autostop")}
variant="outlined"
type="number"
/>
@@ -2,14 +2,14 @@ import { renderWithAuth } from "testHelpers/renderHelpers"
import userEvent from "@testing-library/user-event"
import { screen } from "@testing-library/react"
import {
formValuesToAutoStartRequest,
formValuesToAutostartRequest,
formValuesToTTLRequest,
} from "pages/WorkspaceSchedulePage/formToRequest"
import {
AutoStart,
scheduleToAutoStart,
Autostart,
scheduleToAutostart,
} from "pages/WorkspaceSchedulePage/schedule"
import { AutoStop, ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl"
import { Autostop, ttlMsToAutostop } from "pages/WorkspaceSchedulePage/ttl"
import * as TypesGen from "../../api/typesGenerated"
import {
WorkspaceScheduleFormValues,
@@ -24,7 +24,7 @@ import { MockUser, MockWorkspace } from "testHelpers/entities"
const { t } = i18next
const validValues: WorkspaceScheduleFormValues = {
autoStartEnabled: true,
autostartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -34,19 +34,19 @@ const validValues: WorkspaceScheduleFormValues = {
saturday: false,
startTime: "09:30",
timezone: "Canada/Eastern",
autoStopEnabled: true,
autostopEnabled: true,
ttl: 120,
}
describe("WorkspaceSchedulePage", () => {
describe("formValuesToAutoStartRequest", () => {
describe("formValuesToAutostartRequest", () => {
it.each<
[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceAutostartRequest]
>([
[
// Empty case
{
autoStartEnabled: false,
autostartEnabled: false,
sunday: false,
monday: false,
tuesday: false,
@@ -56,7 +56,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "",
timezone: "",
autoStopEnabled: false,
autostopEnabled: false,
ttl: 0,
},
{
@@ -66,7 +66,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Single day
{
autoStartEnabled: true,
autostartEnabled: true,
sunday: true,
monday: false,
tuesday: false,
@@ -76,7 +76,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "16:20",
timezone: "Canada/Eastern",
autoStopEnabled: true,
autostopEnabled: true,
ttl: 120,
},
{
@@ -86,7 +86,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Standard 1-5 case
{
autoStartEnabled: true,
autostartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -96,7 +96,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "09:30",
timezone: "America/Central",
autoStopEnabled: true,
autostopEnabled: true,
ttl: 120,
},
{
@@ -106,7 +106,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Everyday
{
autoStartEnabled: true,
autostartEnabled: true,
sunday: true,
monday: true,
tuesday: true,
@@ -116,7 +116,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: true,
startTime: "09:00",
timezone: "",
autoStopEnabled: true,
autostopEnabled: true,
ttl: 60 * 8,
},
{
@@ -126,7 +126,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Mon, Wed, Fri Evenings
{
autoStartEnabled: true,
autostartEnabled: true,
sunday: false,
monday: true,
tuesday: false,
@@ -136,15 +136,15 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "16:20",
timezone: "",
autoStopEnabled: true,
autostopEnabled: true,
ttl: 60 * 3,
},
{
schedule: "20 16 * * 1,3,5",
},
],
])(`formValuesToAutoStartRequest(%p) return %p`, (values, request) => {
expect(formValuesToAutoStartRequest(values)).toEqual(request)
])(`formValuesToAutostartRequest(%p) return %p`, (values, request) => {
expect(formValuesToAutostartRequest(values)).toEqual(request)
})
})
@@ -185,13 +185,13 @@ describe("WorkspaceSchedulePage", () => {
})
})
describe("scheduleToAutoStart", () => {
it.each<[string | undefined, AutoStart]>([
describe("scheduleToAutostart", () => {
it.each<[string | undefined, Autostart]>([
// Empty case
[
undefined,
{
autoStartEnabled: false,
autostartEnabled: false,
sunday: false,
monday: false,
tuesday: false,
@@ -208,7 +208,7 @@ describe("WorkspaceSchedulePage", () => {
[
"CRON_TZ=UTC 30 9 * * 1-5",
{
autoStartEnabled: true,
autostartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -225,7 +225,7 @@ describe("WorkspaceSchedulePage", () => {
[
"CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6",
{
autoStartEnabled: true,
autostartEnabled: true,
sunday: false,
monday: true,
tuesday: false,
@@ -237,35 +237,35 @@ describe("WorkspaceSchedulePage", () => {
timezone: "Canada/Eastern",
},
],
])(`scheduleToAutoStart(%p) returns %p`, (schedule, autoStart) => {
expect(scheduleToAutoStart(schedule)).toEqual(autoStart)
])(`scheduleToAutostart(%p) returns %p`, (schedule, autostart) => {
expect(scheduleToAutostart(schedule)).toEqual(autostart)
})
})
describe("ttlMsToAutoStop", () => {
it.each<[number | undefined, AutoStop]>([
describe("ttlMsToAutostop", () => {
it.each<[number | undefined, Autostop]>([
// empty case
[undefined, { autoStopEnabled: false, ttl: 0 }],
[undefined, { autostopEnabled: false, ttl: 0 }],
// zero
[0, { autoStopEnabled: false, ttl: 0 }],
[0, { autostopEnabled: false, ttl: 0 }],
// basic case
[28_800_000, { autoStopEnabled: true, ttl: 8 }],
])(`ttlMsToAutoStop(%p) returns %p`, (ttlMs, autoStop) => {
expect(ttlMsToAutoStop(ttlMs)).toEqual(autoStop)
[28_800_000, { autostopEnabled: true, ttl: 8 }],
])(`ttlMsToAutostop(%p) returns %p`, (ttlMs, autostop) => {
expect(ttlMsToAutostop(ttlMs)).toEqual(autostop)
})
})
describe("autoStop change dialog", () => {
it("shows if autoStop is changed", async () => {
describe("autostop change dialog", () => {
it("shows if autostop is changed", async () => {
renderWithAuth(<WorkspaceSchedulePage />, {
route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
path: "/@:username/:workspace/schedule",
})
const user = userEvent.setup()
const autoStopToggle = await screen.findByLabelText(
const autostopToggle = await screen.findByLabelText(
FormLanguage.stopSwitch,
)
await user.click(autoStopToggle)
await user.click(autostopToggle)
const submitButton = await screen.findByRole("button", {
name: /submit/i,
})
@@ -275,16 +275,16 @@ describe("WorkspaceSchedulePage", () => {
expect(dialog).toBeInTheDocument()
})
it("doesn't show if autoStop is not changed", async () => {
it("doesn't show if autostop is not changed", async () => {
renderWithAuth(<WorkspaceSchedulePage />, {
route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
path: "/@:username/:workspace/schedule",
})
const user = userEvent.setup()
const autoStartToggle = await screen.findByLabelText(
const autostartToggle = await screen.findByLabelText(
FormLanguage.startSwitch,
)
await user.click(autoStartToggle)
await user.click(autostartToggle)
const submitButton = await screen.findByRole("button", {
name: /submit/i,
})
@@ -297,7 +297,7 @@ describe("WorkspaceSchedulePage", () => {
describe("autostop", () => {
it("uses template default ttl when first enabled", async () => {
// have auto-stop disabled
// have autostop disabled
server.use(
rest.get(
"/api/v2/users/:userId/workspace/:workspaceName",
@@ -314,17 +314,17 @@ describe("WorkspaceSchedulePage", () => {
path: "/@:username/:workspace/schedule",
})
const user = userEvent.setup()
const autoStopToggle = await screen.findByLabelText(
const autostopToggle = await screen.findByLabelText(
FormLanguage.stopSwitch,
)
// enable auto-stop
await user.click(autoStopToggle)
// enable autostop
await user.click(autostopToggle)
// find helper text that describes the mock template's 24 hour default
const autoStopHelperText = await screen.findByText(
const autostopHelperText = await screen.findByText(
"Your workspace will shut down a day after",
{ exact: false },
)
expect(autoStopHelperText).toBeDefined()
expect(autostopHelperText).toBeDefined()
})
})
})
@@ -5,8 +5,8 @@ import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import dayjs from "dayjs"
import { scheduleToAutoStart } from "pages/WorkspaceSchedulePage/schedule"
import { ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl"
import { scheduleToAutostart } from "pages/WorkspaceSchedulePage/schedule"
import { ttlMsToAutostop } from "pages/WorkspaceSchedulePage/ttl"
import { useEffect, FC } from "react"
import { useTranslation } from "react-i18next"
import { Navigate, useNavigate, useParams } from "react-router-dom"
@@ -16,14 +16,14 @@ import { WorkspaceScheduleForm } from "../../components/WorkspaceScheduleForm/Wo
import { firstOrItem } from "../../util/array"
import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService"
import {
formValuesToAutoStartRequest,
formValuesToAutostartRequest,
formValuesToTTLRequest,
} from "./formToRequest"
const getAutoStart = (workspace?: TypesGen.Workspace) =>
scheduleToAutoStart(workspace?.autostart_schedule)
const getAutoStop = (workspace?: TypesGen.Workspace) =>
ttlMsToAutoStop(workspace?.ttl_ms)
const getAutostart = (workspace?: TypesGen.Workspace) =>
scheduleToAutostart(workspace?.autostart_schedule)
const getAutostop = (workspace?: TypesGen.Workspace) =>
ttlMsToAutostop(workspace?.ttl_ms)
const useStyles = makeStyles((theme) => ({
topMargin: {
@@ -102,8 +102,8 @@ export const WorkspaceSchedulePage: FC = () => {
<WorkspaceScheduleForm
submitScheduleError={submitScheduleError}
initialValues={{
...getAutoStart(workspace),
...getAutoStop(workspace),
...getAutostart(workspace),
...getAutostop(workspace),
}}
isLoading={scheduleState.tags.has("loading")}
defaultTTL={dayjs.duration(template.default_ttl_ms, "ms").asHours()}
@@ -113,10 +113,10 @@ export const WorkspaceSchedulePage: FC = () => {
onSubmit={(values) => {
scheduleSend({
type: "SUBMIT_SCHEDULE",
autoStart: formValuesToAutoStartRequest(values),
autostart: formValuesToAutostartRequest(values),
ttl: formValuesToTTLRequest(values),
autoStartChanged: scheduleChanged(getAutoStart(workspace), values),
autoStopChanged: scheduleChanged(getAutoStop(workspace), values),
autostartChanged: scheduleChanged(getAutostart(workspace), values),
autostopChanged: scheduleChanged(getAutostop(workspace), values),
})
}}
/>
@@ -1,10 +1,10 @@
import * as TypesGen from "api/typesGenerated"
import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
export const formValuesToAutoStartRequest = (
export const formValuesToAutostartRequest = (
values: WorkspaceScheduleFormValues,
): TypesGen.UpdateWorkspaceAutostartRequest => {
if (!values.autoStartEnabled || !values.startTime) {
if (!values.autostartEnabled || !values.startTime) {
return {
schedule: "",
}
@@ -70,7 +70,7 @@ export const formValuesToTTLRequest = (
return {
// minutes to nanoseconds
ttl_ms:
values.autoStopEnabled && values.ttl
values.autostopEnabled && values.ttl
? values.ttl * 60 * 60 * 1000
: undefined,
}
@@ -10,7 +10,7 @@ import { extractTimezone, stripTimezone } from "../../util/schedule"
dayjs.extend(utc)
dayjs.extend(timezone)
export interface AutoStartSchedule {
export interface AutostartSchedule {
sunday: boolean
monday: boolean
tuesday: boolean
@@ -22,9 +22,9 @@ export interface AutoStartSchedule {
timezone: string
}
export type AutoStart = {
autoStartEnabled: boolean
} & AutoStartSchedule
export type Autostart = {
autostartEnabled: boolean
} & AutostartSchedule
export const emptySchedule = {
sunday: false,
@@ -39,7 +39,7 @@ export const emptySchedule = {
timezone: "",
}
export const defaultSchedule = (): AutoStartSchedule => ({
export const defaultSchedule = (): AutostartSchedule => ({
sunday: false,
monday: true,
tuesday: true,
@@ -79,13 +79,13 @@ const transformSchedule = (schedule: string) => {
}
}
export const scheduleToAutoStart = (schedule?: string): AutoStart => {
export const scheduleToAutostart = (schedule?: string): Autostart => {
if (schedule) {
return {
autoStartEnabled: true,
autostartEnabled: true,
...transformSchedule(schedule),
}
} else {
return { autoStartEnabled: false, ...emptySchedule }
return { autostartEnabled: false, ...emptySchedule }
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
export interface AutoStop {
autoStopEnabled: boolean
export interface Autostop {
autostopEnabled: boolean
ttl: number
}
@@ -7,7 +7,7 @@ export const emptyTTL = 0
const msToHours = (ms: number) => Math.round(ms / (1000 * 60 * 60))
export const ttlMsToAutoStop = (ttl_ms?: number): AutoStop =>
export const ttlMsToAutostop = (ttl_ms?: number): Autostop =>
ttl_ms
? { autoStopEnabled: true, ttl: msToHours(ttl_ms) }
: { autoStopEnabled: false, ttl: 0 }
? { autostopEnabled: true, ttl: msToHours(ttl_ms) }
: { autostopEnabled: false, ttl: 0 }
+26 -26
View File
@@ -79,69 +79,69 @@ describe("getMaxDeadlineChange", () => {
})
describe("scheduleChanged", () => {
describe("autoStart", () => {
describe("autostart", () => {
it("should be true if toggle values are different", () => {
const autoStart = { autoStartEnabled: true, ...emptySchedule }
const autostart = { autostartEnabled: true, ...emptySchedule }
const formValues = {
autoStartEnabled: false,
autostartEnabled: false,
...emptySchedule,
autoStopEnabled: false,
autostopEnabled: false,
ttl: emptyTTL,
}
expect(scheduleChanged(autoStart, formValues)).toBe(true)
expect(scheduleChanged(autostart, formValues)).toBe(true)
})
it("should be true if schedule values are different", () => {
const autoStart = { autoStartEnabled: true, ...emptySchedule }
const autostart = { autostartEnabled: true, ...emptySchedule }
const formValues = {
autoStartEnabled: true,
autostartEnabled: true,
...{ ...emptySchedule, monday: true, startTime: "09:00" },
autoStopEnabled: false,
autostopEnabled: false,
ttl: emptyTTL,
}
expect(scheduleChanged(autoStart, formValues)).toBe(true)
expect(scheduleChanged(autostart, formValues)).toBe(true)
})
it("should be false if all autostart values are the same", () => {
const autoStart = { autoStartEnabled: true, ...emptySchedule }
const autostart = { autostartEnabled: true, ...emptySchedule }
const formValues = {
autoStartEnabled: true,
autostartEnabled: true,
...emptySchedule,
autoStopEnabled: false,
autostopEnabled: false,
ttl: emptyTTL,
}
expect(scheduleChanged(autoStart, formValues)).toBe(false)
expect(scheduleChanged(autostart, formValues)).toBe(false)
})
})
describe("autoStop", () => {
describe("autostop", () => {
it("should be true if toggle values are different", () => {
const autoStop = { autoStopEnabled: true, ttl: 1000 }
const autostop = { autostopEnabled: true, ttl: 1000 }
const formValues = {
autoStartEnabled: false,
autostartEnabled: false,
...emptySchedule,
autoStopEnabled: false,
autostopEnabled: false,
ttl: 1000,
}
expect(scheduleChanged(autoStop, formValues)).toBe(true)
expect(scheduleChanged(autostop, formValues)).toBe(true)
})
it("should be true if ttl values are different", () => {
const autoStop = { autoStopEnabled: true, ttl: 1000 }
const autostop = { autostopEnabled: true, ttl: 1000 }
const formValues = {
autoStartEnabled: false,
autostartEnabled: false,
...emptySchedule,
autoStopEnabled: true,
autostopEnabled: true,
ttl: 2000,
}
expect(scheduleChanged(autoStop, formValues)).toBe(true)
expect(scheduleChanged(autostop, formValues)).toBe(true)
})
it("should be false if all autostop values are the same", () => {
const autoStop = { autoStopEnabled: true, ttl: 1000 }
const autostop = { autostopEnabled: true, ttl: 1000 }
const formValues = {
autoStartEnabled: false,
autostartEnabled: false,
...emptySchedule,
autoStopEnabled: true,
autostopEnabled: true,
ttl: 1000,
}
expect(scheduleChanged(autoStop, formValues)).toBe(false)
expect(scheduleChanged(autostop, formValues)).toBe(false)
})
})
})
+7 -7
View File
@@ -10,8 +10,8 @@ import utc from "dayjs/plugin/utc"
import { Workspace } from "../api/typesGenerated"
import { isWorkspaceOn } from "./workspace"
import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { AutoStop } from "pages/WorkspaceSchedulePage/ttl"
import { AutoStart } from "pages/WorkspaceSchedulePage/schedule"
import { Autostop } from "pages/WorkspaceSchedulePage/ttl"
import { Autostart } from "pages/WorkspaceSchedulePage/schedule"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
@@ -62,11 +62,11 @@ export const Language = {
manual: "Manual",
workspaceShuttingDownLabel: "Workspace is shutting down",
afterStart: "after start",
autoStartLabel: "Starts at",
autoStopLabel: "Stops at",
autostartLabel: "Starts at",
autostopLabel: "Stops at",
}
export const autoStartDisplay = (schedule: string | undefined): string => {
export const autostartDisplay = (schedule: string | undefined): string => {
if (schedule) {
return (
cronstrue
@@ -95,7 +95,7 @@ export const isShuttingDown = (
return isWorkspaceOn(workspace) && now.isAfter(deadline)
}
export const autoStopDisplay = (workspace: Workspace): string => {
export const autostopDisplay = (workspace: Workspace): string => {
const ttl = workspace.ttl_ms
if (isWorkspaceOn(workspace) && workspace.latest_build.deadline) {
@@ -164,7 +164,7 @@ export const getMaxDeadlineChange = (
): number => Math.abs(deadline.diff(extremeDeadline, "hours"))
export const scheduleChanged = (
initialValues: AutoStart | AutoStop,
initialValues: Autostart | Autostop,
formValues: WorkspaceScheduleFormValues,
): boolean =>
some(
@@ -21,7 +21,7 @@ export interface WorkspaceScheduleContext {
permissions?: Permissions
checkPermissionsError?: Error | unknown
submitScheduleError?: Error | unknown
autoStopChanged?: boolean
autostopChanged?: boolean
shouldRestartWorkspace?: boolean
}
@@ -44,10 +44,10 @@ export type WorkspaceScheduleEvent =
| { type: "GET_WORKSPACE"; username: string; workspaceName: string }
| {
type: "SUBMIT_SCHEDULE"
autoStart: TypesGen.UpdateWorkspaceAutostartRequest
autoStartChanged: boolean
autostart: TypesGen.UpdateWorkspaceAutostartRequest
autostartChanged: boolean
ttl: TypesGen.UpdateWorkspaceTTLRequest
autoStopChanged: boolean
autostopChanged: boolean
}
| { type: "RESTART_WORKSPACE" }
| { type: "APPLY_LATER" }
@@ -135,7 +135,7 @@ export const workspaceSchedule =
on: {
SUBMIT_SCHEDULE: {
target: "submittingSchedule",
actions: "assignAutoStopChanged",
actions: "assignAutostopChanged",
},
},
},
@@ -145,7 +145,7 @@ export const workspaceSchedule =
id: "submitSchedule",
onDone: [
{
cond: "autoStopChanged",
cond: "autostopChanged",
target: "showingRestartDialog",
},
{ target: "done" },
@@ -178,7 +178,7 @@ export const workspaceSchedule =
},
{
guards: {
autoStopChanged: (context) => Boolean(context.autoStopChanged),
autostopChanged: (context) => Boolean(context.autostopChanged),
},
actions: {
assignSubmissionError: assign({
@@ -207,8 +207,8 @@ export const workspaceSchedule =
clearGetTemplateError: assign({
getTemplateError: (_) => undefined,
}),
assignAutoStopChanged: assign({
autoStopChanged: (_, event) => event.autoStopChanged,
assignAutostopChanged: assign({
autostopChanged: (_, event) => event.autostopChanged,
}),
clearGetPermissionsError: assign({
checkPermissionsError: (_) => undefined,
@@ -262,13 +262,13 @@ export const workspaceSchedule =
throw new Error("Failed to load workspace.")
}
if (event.autoStartChanged) {
if (event.autostartChanged) {
await API.putWorkspaceAutostart(
context.workspace.id,
event.autoStart,
event.autostart,
)
}
if (event.autoStopChanged) {
if (event.autostopChanged) {
await API.putWorkspaceAutostop(context.workspace.id, event.ttl)
}
},