diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4753aafeef26c..48eabc80c039e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -186,8 +186,9 @@ jobs: - name: Install Protoc run: | - # protoc must be in lockstep with our dogfood Dockerfile - # or the version in the comments will differ. + # protoc must be in lockstep with our dogfood Dockerfile or the + # version in the comments will differ. This is also defined in + # security.yaml set -x cd dogfood DOCKER_BUILDKIT=1 docker build . --target proto -t protoc diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 5689825374b37..ab1e27c225fa7 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -94,6 +94,22 @@ jobs: - name: Install yq run: go run github.com/mikefarah/yq/v4@v4.30.6 + - name: Install protoc-gen-go + run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26 + - name: Install protoc-gen-go-drpc + run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26 + - name: Install Protoc + run: | + # protoc must be in lockstep with our dogfood Dockerfile or the + # version in the comments will differ. This is also defined in + # ci.yaml. + set -x + cd dogfood + DOCKER_BUILDKIT=1 docker build . --target proto -t protoc + protoc_path=/usr/local/bin/protoc + docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path + chmod +x $protoc_path + protoc --version - name: Build Coder linux amd64 Docker image id: build diff --git a/cli/list.go b/cli/list.go index 510589eff64d0..33493cf807080 100644 --- a/cli/list.go +++ b/cli/list.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) diff --git a/cli/schedule.go b/cli/schedule.go index f774f5a51ad87..ff81b8e81dc50 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -10,7 +10,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/coderd/util/tz" "github.com/coder/coder/codersdk" diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 8fc7c9b50b6c8..cd30de7d7f551 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -308,13 +308,16 @@ func TestScheduleOverride(t *testing.T) { 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) { + template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = nil }) cmdArgs = []string{"schedule", "override-stop", workspace.Name, "1h"} stdoutBuf = &bytes.Buffer{} ) + require.Zero(t, template.DefaultTTLMillis) + require.Zero(t, template.MaxTTLMillis) + // Unset the workspace TTL err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) require.NoError(t, err) diff --git a/cli/templateedit.go b/cli/templateedit.go index 5a99634511520..1e487fa8000c2 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "net/http" "time" "github.com/spf13/cobra" @@ -18,6 +19,7 @@ func templateEdit() *cobra.Command { description string icon string defaultTTL time.Duration + maxTTL time.Duration allowUserCancelWorkspaceJobs bool ) @@ -30,6 +32,21 @@ func templateEdit() *cobra.Command { if err != nil { return xerrors.Errorf("create client: %w", err) } + + if maxTTL != 0 { + entitlements, err := client.Entitlements(cmd.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") + } 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") + } + } + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) @@ -46,6 +63,7 @@ func templateEdit() *cobra.Command { Description: description, Icon: icon, DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, } @@ -58,11 +76,12 @@ func templateEdit() *cobra.Command { }, } - cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name") - cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name") - cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description") - cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path") - cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.") + cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name.") + cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name.") + cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description.") + cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path.") + cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template default to this value.") + cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 0, "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.") cmd.Flags().BoolVarP(&allowUserCancelWorkspaceJobs, "allow-user-cancel-workspace-jobs", "", true, "Allow users to cancel in-progress workspace jobs.") cliui.AllowSkipPrompt(cmd) diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 28fe0edabf8c5..4361985feaddd 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -1,8 +1,17 @@ package cli_test import ( + "bytes" "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" "strconv" + "strings" + "sync/atomic" "testing" "time" @@ -11,6 +20,7 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) @@ -230,4 +240,205 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, "", updated.Icon) assert.Equal(t, "", updated.DisplayName) }) + t.Run("MaxTTL", 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. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx, _ := testutil.Context(t) + err := cmd.ExecuteContext(ctx) + 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) + }) + + 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, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // 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, + Experimental: 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. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx, _ := testutil.Context(t) + err = cmd.ExecuteContext(ctx) + 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) + }) + 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, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // 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, + Experimental: 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.Equal(t, time.Hour.Milliseconds(), req.MaxTTLMillis) + + 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, + "--max-ttl", "1h", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx, _ := testutil.Context(t) + err = cmd.ExecuteContext(ctx) + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled)) + + // 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) + }) + }) } diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index ac3761efd6dca..9a39ed4f9c3dc 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -39,6 +39,7 @@ "reason": "initiator", "resources": [], "deadline": "[timestamp]", + "max_deadline": null, "status": "running", "daily_cost": 0 }, diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index f92ba830ad7c6..5bcc48e9a5ae3 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -7,12 +7,17 @@ Flags: --allow-user-cancel-workspace-jobs Allow users to cancel in-progress workspace jobs. (default true) --default-ttl duration Edit the template default time before shutdown - - workspaces created from this template to this value. - --description string Edit the template description - --display-name string Edit the template display name + workspaces created from this template default to + this value. + --description string Edit the template description. + --display-name string Edit the template display name. -h, --help help for edit - --icon string Edit the template icon path - --name string Edit the template name + --icon string Edit the template icon path. + --max-ttl duration Edit the template maximum time before shutdown - + workspaces created from this template must shutdown + within the given duration after starting. This is + an enterprise-only feature. + --name string Edit the template name. -y, --yes Bypass prompts Global Flags: diff --git a/cli/util.go b/cli/util.go index 7ea5dd27e756b..777335d0a7d80 100644 --- a/cli/util.go +++ b/cli/util.go @@ -8,7 +8,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/tz" ) diff --git a/coderd/activitybump.go b/coderd/activitybump.go index 2c6c5e38bf0be..18ca36b062beb 100644 --- a/coderd/activitybump.go +++ b/coderd/activitybump.go @@ -70,12 +70,16 @@ func activityBumpWorkspace(ctx context.Context, log slog.Logger, db database.Sto } newDeadline := database.Now().Add(bumpAmount) + if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) { + newDeadline = build.MaxDeadline + } if _, err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: build.ID, UpdatedAt: database.Now(), ProvisionerState: build.ProvisionerState, Deadline: newDeadline, + MaxDeadline: build.MaxDeadline, }); err != nil { return xerrors.Errorf("update workspace build: %w", err) } diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index fe8c947dcea62..1548eddbb057c 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -5,24 +5,51 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "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() ctx := context.Background() - const ttl = time.Minute - - setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { - ttlMillis := int64(ttl / time.Millisecond) + setupActivityTest := func(t *testing.T, maxDeadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { + const ttl = time.Minute + maxTTL := time.Duration(0) + if len(maxDeadline) > 0 { + maxTTL = maxDeadline[0] + } client = coderdtest.New(t, &coderdtest.Options{ AppHostname: proxyTestSubdomainRaw, @@ -30,9 +57,19 @@ 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) { + return schedule.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: ttl, + MaxTTL: maxTTL, + }, nil + }, + }, }) user := coderdtest.CreateFirstUser(t, client) + ttlMillis := int64(ttl / time.Millisecond) workspace = createWorkspaceWithApps(t, client, user.OrganizationID, "", 1234, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = &ttlMillis }) @@ -42,10 +79,21 @@ func TestWorkspaceActivityBump(t *testing.T) { require.NoError(t, err) require.WithinDuration(t, time.Now().Add(time.Duration(ttlMillis)*time.Millisecond), - workspace.LatestBuild.Deadline.Time, testutil.WaitMedium, + workspace.LatestBuild.Deadline.Time, + testutil.WaitMedium, ) firstDeadline := workspace.LatestBuild.Deadline.Time + if maxTTL != 0 { + require.WithinDuration(t, + time.Now().Add(maxTTL), + workspace.LatestBuild.MaxDeadline.Time, + testutil.WaitMedium, + ) + } else { + require.True(t, workspace.LatestBuild.MaxDeadline.Time.IsZero()) + } + _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) return client, workspace, func(want bool) { @@ -74,6 +122,12 @@ func TestWorkspaceActivityBump(t *testing.T) { "deadline %v never updated", firstDeadline, ) + // If the workspace has a max deadline, the deadline must not exceed + // it. + if maxTTL != 0 && database.Now().Add(ttl).After(workspace.LatestBuild.MaxDeadline.Time) { + require.Equal(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time) + return + } require.WithinDuration(t, database.Now().Add(ttl), workspace.LatestBuild.Deadline.Time, 3*time.Second) } } @@ -111,4 +165,34 @@ func TestWorkspaceActivityBump(t *testing.T) { assertBumped(false) }) + + t.Run("NotExceedMaxDeadline", func(t *testing.T) { + t.Parallel() + + // Set the max deadline to be in 61 seconds. We bump by 1 minute, so we + // should expect the deadline to match the max deadline exactly. + client, workspace, assertBumped := setupActivityTest(t, 61*time.Second) + + // Bump by dialing the workspace and sending traffic. + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil), + }) + require.NoError(t, err) + defer conn.Close() + + // Must send network traffic after a few seconds to surpass bump threshold. + time.Sleep(time.Second * 3) + sshConn, err := conn.SSHClient(ctx) + require.NoError(t, err) + _ = sshConn.Close() + + assertBumped(true) + + // Double check that the workspace build's deadline is equal to the + // max deadline. + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time) + }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0ca4aa61e78d3..229f1c63bc79d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5875,6 +5875,10 @@ const docTemplate = `{ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, + "max_ttl_ms": { + "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", + "type": "integer" + }, "name": { "description": "Name is the name of the template.", "type": "string" @@ -7729,6 +7733,10 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "max_ttl_ms": { + "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", + "type": "integer" + }, "name": { "type": "string" }, @@ -8653,6 +8661,10 @@ const docTemplate = `{ "job": { "$ref": "#/definitions/codersdk.ProvisionerJob" }, + "max_deadline": { + "type": "string", + "format": "date-time" + }, "reason": { "enum": [ "initiator", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7dd83af9690ff..398b7e5ddcc7e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5215,6 +5215,10 @@ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, + "max_ttl_ms": { + "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", + "type": "integer" + }, "name": { "description": "Name is the name of the template.", "type": "string" @@ -6950,6 +6954,10 @@ "type": "string", "format": "uuid" }, + "max_ttl_ms": { + "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", + "type": "integer" + }, "name": { "type": "string" }, @@ -7802,6 +7810,10 @@ "job": { "$ref": "#/definitions/codersdk.ProvisionerJob" }, + "max_deadline": { + "type": "string", + "format": "date-time" + }, "reason": { "enum": ["initiator", "autostart", "autostop"], "allOf": [ diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 6e72dff6af272..f6b4d0db12d87 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -10,10 +10,10 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/provisionerdserver" + "github.com/coder/coder/coderd/schedule" ) // Executor automatically starts or stops workspaces. diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index ac0ee6c6254f8..2548e317b69ff 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -11,9 +11,9 @@ import ( "github.com/google/uuid" "github.com/coder/coder/coderd/autobuild/executor" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" diff --git a/coderd/coderd.go b/coderd/coderd.go index 1b91d56539502..450e1bc0746d7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -51,6 +51,7 @@ import ( "github.com/coder/coder/coderd/metricscache" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/updatecheck" @@ -112,12 +113,13 @@ type Options struct { RealIPConfig *httpmw.RealIPConfig TrialGenerator func(ctx context.Context, email string) error // TLSCertificates is used to mesh DERP servers securely. - TLSCertificates []tls.Certificate - TailnetCoordinator tailnet.Coordinator - DERPServer *derp.Server - DERPMap *tailcfg.DERPMap - SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server + DERPMap *tailcfg.DERPMap + SwaggerEndpoint bool + SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TemplateScheduleStore schedule.TemplateScheduleStore // APIRateLimit is the minutely throughput rate limit per user or ip. // Setting a rate limit <0 will disable the rate limiter across the entire @@ -209,6 +211,9 @@ func New(options *Options) *API { if options.SetUserGroups == nil { options.SetUserGroups = func(context.Context, database.Store, uuid.UUID, []string) error { return nil } } + if options.TemplateScheduleStore == nil { + options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() + } siteCacheDir := options.CacheDir if siteCacheDir != "" { @@ -245,9 +250,10 @@ func New(options *Options) *API { Authorizer: options.Authorizer, Logger: options.Logger, }, - metricsCache: metricsCache, - Auditor: atomic.Pointer[audit.Auditor]{}, - Experiments: experiments, + metricsCache: metricsCache, + Auditor: atomic.Pointer[audit.Auditor]{}, + TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{}, + Experiments: experiments, } if options.UpdateCheckOptions != nil { api.updateChecker = updatecheck.New( @@ -257,6 +263,7 @@ 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) oauthConfigs := &httpmw.OAuth2Configs{ @@ -720,6 +727,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] HTTPAuth *HTTPAuthorizer @@ -820,18 +828,19 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti gitAuthProviders = append(gitAuthProviders, cfg.ID) } err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - GitAuthProviders: gitAuthProviders, - Telemetry: api.Telemetry, - Tags: tags, - QuotaCommitter: &api.QuotaCommitter, - Auditor: &api.Auditor, - AcquireJobDebounce: debounce, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + AccessURL: api.AccessURL, + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + GitAuthProviders: gitAuthProviders, + Telemetry: api.Telemetry, + Tags: tags, + QuotaCommitter: &api.QuotaCommitter, + Auditor: &api.Auditor, + TemplateScheduleStore: &api.TemplateScheduleStore, + AcquireJobDebounce: debounce, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), }) if err != nil { return nil, err diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7f94e852badc8..e0b9a53d4e296 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -66,6 +66,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/updatecheck" "github.com/coder/coder/coderd/util/ptr" @@ -85,22 +86,23 @@ type Options struct { // AccessURL denotes a custom access URL. By default we use the httptest // server's URL. Setting this may result in unexpected behavior (especially // with running agents). - AccessURL *url.URL - AppHostname string - AWSCertificates awsidentity.Certificates - Authorizer rbac.Authorizer - AzureCertificates x509.VerifyOptions - GithubOAuth2Config *coderd.GithubOAuth2Config - RealIPConfig *httpmw.RealIPConfig - OIDCConfig *coderd.OIDCConfig - GoogleTokenValidator *idtoken.Validator - SSHKeygenAlgorithm gitsshkey.Algorithm - AutobuildTicker <-chan time.Time - AutobuildStats chan<- executor.Stats - Auditor audit.Auditor - TLSCertificates []tls.Certificate - GitAuthConfigs []*gitauth.Config - TrialGenerator func(context.Context, string) error + AccessURL *url.URL + AppHostname string + AWSCertificates awsidentity.Certificates + Authorizer rbac.Authorizer + AzureCertificates x509.VerifyOptions + GithubOAuth2Config *coderd.GithubOAuth2Config + RealIPConfig *httpmw.RealIPConfig + OIDCConfig *coderd.OIDCConfig + GoogleTokenValidator *idtoken.Validator + SSHKeygenAlgorithm gitsshkey.Algorithm + AutobuildTicker <-chan time.Time + AutobuildStats chan<- executor.Stats + Auditor audit.Auditor + TLSCertificates []tls.Certificate + GitAuthConfigs []*gitauth.Config + TrialGenerator func(context.Context, string) error + TemplateScheduleStore schedule.TemplateScheduleStore // All rate limits default to -1 (unlimited) in tests if not set. APIRateLimit int @@ -287,22 +289,23 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can Pubsub: options.Pubsub, GitAuthConfigs: options.GitAuthConfigs, - Auditor: options.Auditor, - AWSCertificates: options.AWSCertificates, - AzureCertificates: options.AzureCertificates, - GithubOAuth2Config: options.GithubOAuth2Config, - RealIPConfig: options.RealIPConfig, - OIDCConfig: options.OIDCConfig, - GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - DERPServer: derpServer, - APIRateLimit: options.APIRateLimit, - LoginRateLimit: options.LoginRateLimit, - FilesRateLimit: options.FilesRateLimit, - Authorizer: options.Authorizer, - Telemetry: telemetry.NewNoop(), - TLSCertificates: options.TLSCertificates, - TrialGenerator: options.TrialGenerator, + Auditor: options.Auditor, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, + RealIPConfig: options.RealIPConfig, + OIDCConfig: options.OIDCConfig, + GoogleTokenValidator: options.GoogleTokenValidator, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + DERPServer: derpServer, + APIRateLimit: options.APIRateLimit, + LoginRateLimit: options.LoginRateLimit, + FilesRateLimit: options.FilesRateLimit, + Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), + TemplateScheduleStore: options.TemplateScheduleStore, + TLSCertificates: options.TLSCertificates, + TrialGenerator: options.TrialGenerator, DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 5032bbd0abcff..94b36e4306734 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -825,6 +825,13 @@ func (q *querier) UpdateTemplateMetaByID(ctx context.Context, arg database.Updat return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateTemplateMetaByID)(ctx, arg) } +func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) { + fetch := func(ctx context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) +} + func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { template, err := q.db.GetTemplateByID(ctx, arg.TemplateID.UUID) if err != nil { @@ -1496,6 +1503,13 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } +func (q *querier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.TemplateID) + } + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspaceTTLToBeWithinTemplateMax)(ctx, arg) +} + func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWorkspaceTTLParams) error { fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index e0ac02dbcfeb5..92b305c837546 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -1686,8 +1686,8 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd return database.Template{}, err } - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() for idx, tpl := range q.templates { if tpl.ID != arg.ID { @@ -1698,7 +1698,28 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd tpl.DisplayName = arg.DisplayName tpl.Description = arg.Description tpl.Icon = arg.Icon + q.templates[idx] = tpl + return tpl, nil + } + + return database.Template{}, sql.ErrNoRows +} + +func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) { + if err := validateDatabaseType(arg); err != nil { + return database.Template{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for idx, tpl := range q.templates { + if tpl.ID != arg.ID { + continue + } + tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL + tpl.MaxTTL = arg.MaxTTL q.templates[idx] = tpl return tpl, nil } @@ -2637,7 +2658,6 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl Provisioner: arg.Provisioner, ActiveVersionID: arg.ActiveVersionID, Description: arg.Description, - DefaultTTL: arg.DefaultTTL, CreatedBy: arg.CreatedBy, UserACL: arg.UserACL, GroupACL: arg.GroupACL, @@ -3532,6 +3552,26 @@ func (q *fakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database. return sql.ErrNoRows } +func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.TemplateID != arg.TemplateID || !workspace.Ttl.Valid || workspace.Ttl.Int64 < arg.TemplateMaxTTL { + continue + } + + workspace.Ttl = sql.NullInt64{Int64: arg.TemplateMaxTTL, Valid: true} + q.workspaces[index] = workspace + } + + return nil +} + func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.UpdateWorkspaceBuildByIDParams) (database.WorkspaceBuild, error) { if err := validateDatabaseType(arg); err != nil { return database.WorkspaceBuild{}, err @@ -3547,6 +3587,7 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U workspaceBuild.UpdatedAt = arg.UpdatedAt workspaceBuild.ProvisionerState = arg.ProvisionerState workspaceBuild.Deadline = arg.Deadline + workspaceBuild.MaxDeadline = arg.MaxDeadline q.workspaceBuilds[index] = workspaceBuild return workspaceBuild, nil } diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index f272f30ec7a6c..69e3dc76253ed 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -60,7 +60,6 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. Provisioner: takeFirst(seed.Provisioner, database.ProvisionerTypeEcho), ActiveVersionID: takeFirst(seed.ActiveVersionID, uuid.New()), Description: takeFirst(seed.Description, namesgenerator.GetRandomName(1)), - DefaultTTL: takeFirst(seed.DefaultTTL, 3600), CreatedBy: takeFirst(seed.CreatedBy, uuid.New()), Icon: takeFirst(seed.Icon, namesgenerator.GetRandomName(1)), UserACL: seed.UserACL, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 264108c0e844c..f479a23134c5c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -434,7 +434,8 @@ CREATE TABLE templates ( user_acl jsonb DEFAULT '{}'::jsonb NOT NULL, 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 + allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL, + max_ttl bigint DEFAULT '0'::bigint NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.'; @@ -579,7 +580,8 @@ CREATE TABLE workspace_builds ( job_id uuid NOT NULL, deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, reason build_reason DEFAULT 'initiator'::build_reason NOT NULL, - daily_cost integer DEFAULT 0 NOT NULL + daily_cost integer DEFAULT 0 NOT NULL, + max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL ); CREATE TABLE workspace_resource_metadata ( diff --git a/coderd/database/migrations/000105_workspace_build_max_deadline.down.sql b/coderd/database/migrations/000105_workspace_build_max_deadline.down.sql new file mode 100644 index 0000000000000..2fecc6595bbc1 --- /dev/null +++ b/coderd/database/migrations/000105_workspace_build_max_deadline.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE "workspace_builds" DROP COLUMN "max_deadline"; + +ALTER TABLE "templates" DROP COLUMN "max_ttl"; diff --git a/coderd/database/migrations/000105_workspace_build_max_deadline.up.sql b/coderd/database/migrations/000105_workspace_build_max_deadline.up.sql new file mode 100644 index 0000000000000..4a382fd9f107c --- /dev/null +++ b/coderd/database/migrations/000105_workspace_build_max_deadline.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE "templates" ADD COLUMN "max_ttl" bigint DEFAULT '0'::bigint NOT NULL; + +ALTER TABLE "workspace_builds" ADD COLUMN "max_deadline" timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 348555285ad03..a9bdfca75d46f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -77,6 +77,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ); err != nil { return nil, err } diff --git a/coderd/database/models.go b/coderd/database/models.go index 8f9673d56438b..a697c7997cd4e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1423,7 +1423,8 @@ type Template struct { // Display name is a custom, human-friendly template name that user can set. DisplayName string `db:"display_name" json:"display_name"` // Allow users to cancel in-progress workspace jobs. - AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` } type TemplateVersion struct { @@ -1617,6 +1618,7 @@ type WorkspaceBuild struct { Deadline time.Time `db:"deadline" json:"deadline"` Reason BuildReason `db:"reason" json:"reason"` DailyCost int32 `db:"daily_cost" json:"daily_cost"` + MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` } type WorkspaceBuildParameter struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 667a3206f494b..77e83d0ad2a89 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -190,6 +190,7 @@ type sqlcQuerier interface { UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) + UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionGitAuthProvidersByJobIDParams) error @@ -212,6 +213,7 @@ type sqlcQuerier interface { UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error + UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error } var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fd7381512bc97..1a668d78e84f4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2387,7 +2387,7 @@ WHERE -- Ensure the caller has the correct provisioner. AND nested.provisioner = ANY($3 :: provisioner_type [ ]) -- Ensure the caller satisfies all job tags. - AND nested.tags <@ $4 :: jsonb + AND nested.tags <@ $4 :: jsonb ORDER BY nested.created_at FOR UPDATE @@ -3074,7 +3074,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 + 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 WHERE @@ -3103,13 +3103,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ) 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 + 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 WHERE @@ -3146,12 +3147,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ) 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 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 FROM templates ORDER BY (name, id) ASC ` @@ -3181,6 +3183,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ); err != nil { return nil, err } @@ -3197,7 +3200,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 + 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 WHERE @@ -3264,6 +3267,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ); err != nil { return nil, err } @@ -3289,7 +3293,6 @@ INSERT INTO provisioner, active_version_id, description, - default_ttl, created_by, icon, user_acl, @@ -3298,7 +3301,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) 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 + ($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 ` type InsertTemplateParams struct { @@ -3310,7 +3313,6 @@ type InsertTemplateParams struct { Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` Description string `db:"description" json:"description"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` UserACL TemplateACL `db:"user_acl" json:"user_acl"` @@ -3329,7 +3331,6 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.Provisioner, arg.ActiveVersionID, arg.Description, - arg.DefaultTTL, arg.CreatedBy, arg.Icon, arg.UserACL, @@ -3355,6 +3356,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ) return i, err } @@ -3368,7 +3370,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 + 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 ` type UpdateTemplateACLByIDParams struct { @@ -3397,6 +3399,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ) return i, err } @@ -3449,22 +3452,20 @@ UPDATE SET updated_at = $2, description = $3, - default_ttl = $4, - name = $5, - icon = $6, - display_name = $7, - allow_user_cancel_workspace_jobs = $8 + name = $4, + icon = $5, + display_name = $6, + allow_user_cancel_workspace_jobs = $7 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 + 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 ` type UpdateTemplateMetaByIDParams struct { ID uuid.UUID `db:"id" json:"id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Description string `db:"description" json:"description"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` Name string `db:"name" json:"name"` Icon string `db:"icon" json:"icon"` DisplayName string `db:"display_name" json:"display_name"` @@ -3476,7 +3477,6 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.ID, arg.UpdatedAt, arg.Description, - arg.DefaultTTL, arg.Name, arg.Icon, arg.DisplayName, @@ -3500,6 +3500,57 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, + ) + return i, err +} + +const updateTemplateScheduleByID = `-- name: UpdateTemplateScheduleByID :one +UPDATE + templates +SET + updated_at = $2, + default_ttl = $3, + max_ttl = $4 +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 +` + +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"` +} + +func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) { + row := q.db.QueryRowContext(ctx, updateTemplateScheduleByID, + arg.ID, + arg.UpdatedAt, + arg.DefaultTTL, + arg.MaxTTL, + ) + var i Template + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OrganizationID, + &i.Deleted, + &i.Name, + &i.Provisioner, + &i.ActiveVersionID, + &i.Description, + &i.DefaultTTL, + &i.CreatedBy, + &i.Icon, + &i.UserACL, + &i.GroupACL, + &i.DisplayName, + &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ) return i, err } @@ -5870,7 +5921,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -5898,12 +5949,13 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -5940,6 +5992,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -5955,7 +6008,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -5994,6 +6047,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -6010,7 +6064,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -6036,13 +6090,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -6068,13 +6123,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -6104,13 +6160,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -6179,6 +6236,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -6194,7 +6252,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost FROM workspace_builds WHERE created_at > $1 +SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -6220,6 +6278,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -6248,10 +6307,11 @@ INSERT INTO job_id, provisioner_state, deadline, + max_deadline, reason ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline ` type InsertWorkspaceBuildParams struct { @@ -6266,6 +6326,7 @@ type InsertWorkspaceBuildParams struct { JobID uuid.UUID `db:"job_id" json:"job_id"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` Deadline time.Time `db:"deadline" json:"deadline"` + MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` Reason BuildReason `db:"reason" json:"reason"` } @@ -6282,6 +6343,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa arg.JobID, arg.ProvisionerState, arg.Deadline, + arg.MaxDeadline, arg.Reason, ) var i WorkspaceBuild @@ -6299,6 +6361,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } @@ -6309,9 +6372,10 @@ UPDATE SET updated_at = $2, provisioner_state = $3, - deadline = $4 + deadline = $4, + max_deadline = $5 WHERE - id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline ` type UpdateWorkspaceBuildByIDParams struct { @@ -6319,6 +6383,7 @@ type UpdateWorkspaceBuildByIDParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` Deadline time.Time `db:"deadline" json:"deadline"` + MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` } func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) (WorkspaceBuild, error) { @@ -6327,6 +6392,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor arg.UpdatedAt, arg.ProvisionerState, arg.Deadline, + arg.MaxDeadline, ) var i WorkspaceBuild err := row.Scan( @@ -6343,6 +6409,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } @@ -6353,7 +6420,7 @@ UPDATE SET daily_cost = $2 WHERE - id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline ` type UpdateWorkspaceBuildCostByIDParams struct { @@ -6378,6 +6445,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg Updat &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } @@ -7298,3 +7366,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) return err } + +const updateWorkspaceTTLToBeWithinTemplateMax = `-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec +UPDATE + workspaces +SET + ttl = LEAST(ttl, $1::bigint) +WHERE + template_id = $2 + -- LEAST() does not pick NULL, so filter it out as we don't want to set a + -- TTL on the workspace if it's unset. + -- + -- During build time, the template max TTL will still be used if the + -- workspace TTL is NULL. + AND ttl IS NOT NULL +` + +type UpdateWorkspaceTTLToBeWithinTemplateMaxParams struct { + TemplateMaxTTL int64 `db:"template_max_ttl" json:"template_max_ttl"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +func (q *sqlQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceTTLToBeWithinTemplateMax, arg.TemplateMaxTTL, arg.TemplateID) + return err +} diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index 8f68546a58ef3..7626c52251280 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -22,7 +22,7 @@ WHERE -- Ensure the caller has the correct provisioner. AND nested.provisioner = ANY(@types :: provisioner_type [ ]) -- Ensure the caller satisfies all job tags. - AND nested.tags <@ @tags :: jsonb + AND nested.tags <@ @tags :: jsonb ORDER BY nested.created_at FOR UPDATE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 5fa8270b5ae33..03b9c9bccc954 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -67,7 +67,6 @@ INSERT INTO provisioner, active_version_id, description, - default_ttl, created_by, icon, user_acl, @@ -76,7 +75,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE @@ -102,11 +101,22 @@ UPDATE SET updated_at = $2, description = $3, - default_ttl = $4, - name = $5, - icon = $6, - display_name = $7, - allow_user_cancel_workspace_jobs = $8 + name = $4, + icon = $5, + display_name = $6, + allow_user_cancel_workspace_jobs = $7 +WHERE + id = $1 +RETURNING + *; + +-- name: UpdateTemplateScheduleByID :one +UPDATE + templates +SET + updated_at = $2, + default_ttl = $3, + max_ttl = $4 WHERE id = $1 RETURNING diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 30658634da4e0..b56be8f1d1de5 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -119,10 +119,11 @@ INSERT INTO job_id, provisioner_state, deadline, + max_deadline, reason ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateWorkspaceBuildByID :one UPDATE @@ -130,7 +131,8 @@ UPDATE SET updated_at = $2, provisioner_state = $3, - deadline = $4 + deadline = $4, + max_deadline = $5 WHERE id = $1 RETURNING *; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index def4436bed94c..3beedc6932089 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -316,3 +316,17 @@ SET last_used_at = $2 WHERE id = $1; + +-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec +UPDATE + workspaces +SET + ttl = LEAST(ttl, @template_max_ttl::bigint) +WHERE + template_id = @template_id + -- LEAST() does not pick NULL, so filter it out as we don't want to set a + -- TTL on the workspace if it's unset. + -- + -- During build time, the template max TTL will still be used if the + -- workspace TTL is NULL. + AND ttl IS NOT NULL; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 709ee213aca20..fcc5d777003ca 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -47,6 +47,8 @@ overrides: group_acl: GroupACL troubleshooting_url: TroubleshootingURL default_ttl: DefaultTTL + max_ttl: MaxTTL + template_max_ttl: TemplateMaxTTL motd_file: MOTDFile uuid: UUID diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index ce2c8aa220cad..287a7addc2ddb 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/parameter" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" @@ -43,17 +44,18 @@ var ( ) type Server struct { - AccessURL *url.URL - ID uuid.UUID - Logger slog.Logger - Provisioners []database.ProvisionerType - GitAuthProviders []string - Tags json.RawMessage - Database database.Store - Pubsub database.Pubsub - Telemetry telemetry.Reporter - QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] - Auditor *atomic.Pointer[audit.Auditor] + AccessURL *url.URL + ID uuid.UUID + Logger slog.Logger + Provisioners []database.ProvisionerType + GitAuthProviders []string + Tags json.RawMessage + Database database.Store + Pubsub database.Pubsub + Telemetry telemetry.Reporter + QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] + Auditor *atomic.Pointer[audit.Auditor] + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] AcquireJobDebounce time.Duration } @@ -661,15 +663,31 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p if err != nil { return nil, xerrors.Errorf("unmarshal workspace provision input: %w", err) } - build, err := server.Database.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ - ID: input.WorkspaceBuildID, - UpdatedAt: database.Now(), - ProvisionerState: jobType.WorkspaceBuild.State, - // We are explicitly not updating deadline here. - }) + + var build database.WorkspaceBuild + err := server.Database.InTx(func(db database.Store) error { + workspaceBuild, err := db.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) + if err != nil { + return xerrors.Errorf("get workspace build: %w", err) + } + + build, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: input.WorkspaceBuildID, + UpdatedAt: database.Now(), + ProvisionerState: jobType.WorkspaceBuild.State, + Deadline: workspaceBuild.Deadline, + MaxDeadline: workspaceBuild.MaxDeadline, + }) + if err != nil { + return xerrors.Errorf("update workspace build state: %w", err) + } + + return nil + }, nil) if err != nil { - return nil, xerrors.Errorf("update workspace build state: %w", err) + return nil, err } + err = server.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), []byte{}) if err != nil { return nil, xerrors.Errorf("update workspace: %w", err) @@ -739,6 +757,8 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p } // CompleteJob is triggered by a provision daemon to mark a provisioner job as completed. +// +//nolint:gocyclo func (server *Server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) { //nolint:gocritic // Provisionerd has specific authz rules. ctx = dbauthz.AsProvisionerd(ctx) @@ -867,18 +887,48 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete var getWorkspaceError error err = server.Database.InTx(func(db database.Store) error { - now := database.Now() - var workspaceDeadline time.Time + var ( + now = database.Now() + // deadline is the time when the workspace will be stopped. The + // value can be bumped by user activity or manually by the user + // via the UI. + deadline time.Time + // maxDeadline is the maximum value for deadline. + maxDeadline time.Time + ) + workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) - if getWorkspaceError == nil { - if workspace.Ttl.Valid { - workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64)) + if getWorkspaceError != nil { + server.Logger.Error(ctx, + "fetch workspace for build", + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("workspace_id", workspaceBuild.WorkspaceID), + ) + return getWorkspaceError + } + if workspace.Ttl.Valid { + deadline = now.Add(time.Duration(workspace.Ttl.Int64)) + } + + templateSchedule, err := (*server.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, db, workspace.TemplateID) + 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. + deadline = time.Time{} + } + if templateSchedule.MaxTTL > 0 { + maxDeadline = now.Add(templateSchedule.MaxTTL) + + if deadline.IsZero() || maxDeadline.Before(deadline) { + // If the workspace doesn't have a deadline or the max + // deadline is sooner than the workspace deadline, use the + // max deadline as the actual deadline. + deadline = maxDeadline } - } else { - // Huh? Did the workspace get deleted? - // In any case, since this is just for the TTL, try and continue anyway. - server.Logger.Error(ctx, "fetch workspace for build", slog.F("workspace_build_id", workspaceBuild.ID), slog.F("workspace_id", workspaceBuild.WorkspaceID)) } + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, UpdatedAt: database.Now(), @@ -892,7 +942,8 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete } _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: workspaceBuild.ID, - Deadline: workspaceDeadline, + Deadline: deadline, + MaxDeadline: maxDeadline, ProvisionerState: jobType.WorkspaceBuild.State, UpdatedAt: now, }) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 2eea229e7c8f7..7663dd6d50090 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/coderd/database/dbfake" "github.com/coder/coder/coderd/database/dbgen" "github.com/coder/coder/coderd/provisionerdserver" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd/proto" @@ -32,6 +33,13 @@ func mockAuditor() *atomic.Pointer[audit.Auditor] { return ptr } +func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] { + ptr := &atomic.Pointer[schedule.TemplateScheduleStore]{} + store := schedule.NewAGPLTemplateScheduleStore() + ptr.Store(&store) + return ptr +} + func TestAcquireJob(t *testing.T) { t.Parallel() t.Run("Debounce", func(t *testing.T) { @@ -39,15 +47,16 @@ func TestAcquireJob(t *testing.T) { db := dbfake.New() pubsub := database.NewPubsubInMemory() srv := &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, nil), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: pubsub, - Telemetry: telemetry.NewNoop(), - AcquireJobDebounce: time.Hour, - Auditor: mockAuditor(), + ID: uuid.New(), + Logger: slogtest.Make(t, nil), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: pubsub, + Telemetry: telemetry.NewNoop(), + AcquireJobDebounce: time.Hour, + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), } job, err := srv.AcquireJob(context.Background(), nil) require.NoError(t, err) @@ -784,74 +793,226 @@ func TestCompleteJob(t *testing.T) { require.NoError(t, err) require.False(t, job.Error.Valid) }) + t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() - srv := setup(t, false) - workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{ - ID: uuid.New(), - }) - require.NoError(t, err) - build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ - ID: uuid.New(), - WorkspaceID: workspace.ID, - Transition: database.WorkspaceTransitionDelete, - Reason: database.BuildReasonInitiator, - }) - require.NoError(t, err) - input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, - }) - require.NoError(t, err) - job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - Provisioner: database.ProvisionerTypeEcho, - Input: input, - Type: database.ProvisionerJobTypeWorkspaceBuild, - StorageMethod: database.ProvisionerStorageMethodFile, - }) - require.NoError(t, err) - _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ - WorkerID: uuid.NullUUID{ - UUID: srv.ID, - Valid: true, + + cases := []struct { + name string + 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. + expectedTTL time.Duration + expectedMaxTTL time.Duration + }{ + { + name: "OK", + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 0, + expectedMaxTTL: 0, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, - }) - require.NoError(t, err) + { + name: "Delete", + 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: "TemplateDefaultTTLIgnored", + 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: "TemplateMaxTTL", + 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: "TemplateMaxTTLOverridesTemplateDefaultTTL", + templateDefaultTTL: 3 * time.Hour, + templateMaxTTL: 2 * time.Hour, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 2 * time.Hour, + expectedMaxTTL: 2 * time.Hour, + }, + } - publishedWorkspace := make(chan struct{}) - closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { - close(publishedWorkspace) - }) - require.NoError(t, err) - defer closeWorkspaceSubscribe() - publishedLogs := make(chan struct{}) - closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionerdserver.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) { - close(publishedLogs) - }) - require.NoError(t, err) - defer closeLogsSubscribe() + for _, c := range cases { + c := c - _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ - JobId: job.ID.String(), - Type: &proto.CompletedJob_WorkspaceBuild_{ - WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ - State: []byte{}, - Resources: []*sdkproto.Resource{{ - Name: "example", - Type: "aws_instance", - }}, - }, - }, - }) - require.NoError(t, err) + t.Run(c.name, func(t *testing.T) { + t.Parallel() - <-publishedWorkspace - <-publishedLogs + srv := setup(t, false) - workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID) - require.NoError(t, err) - require.True(t, workspace.Deleted) + var store schedule.TemplateScheduleStore = mockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, + }, nil + }, + } + srv.TemplateScheduleStore.Store(&store) + + user := dbgen.User(t, srv.Database, database.User{}) + template := dbgen.Template(t, srv.Database, database.Template{ + Name: "template", + 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), + }) + require.NoError(t, err) + file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) + workspaceTTL := sql.NullInt64{} + if c.workspaceTTL != 0 { + workspaceTTL = sql.NullInt64{ + Int64: int64(c.workspaceTTL), + Valid: true, + } + } + workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{ + ID: uuid.New(), + TemplateID: template.ID, + Ttl: workspaceTTL, + }) + version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + require.NoError(t, err) + build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + Transition: c.transition, + Reason: database.BuildReasonInitiator, + }) + require.NoError(t, err) + job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + FileID: file.ID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeWorkspaceBuild, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + }) + require.NoError(t, err) + _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{ + UUID: srv.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + publishedWorkspace := make(chan struct{}) + closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { + close(publishedWorkspace) + }) + require.NoError(t, err) + defer closeWorkspaceSubscribe() + publishedLogs := make(chan struct{}) + closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionerdserver.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) { + close(publishedLogs) + }) + require.NoError(t, err) + defer closeLogsSubscribe() + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "example", + Type: "aws_instance", + }}, + }, + }, + }) + require.NoError(t, err) + + <-publishedWorkspace + <-publishedLogs + + workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, c.transition == database.WorkspaceTransitionDelete, workspace.Deleted) + + workspaceBuild, err := srv.Database.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + + if c.expectedTTL == 0 { + require.True(t, workspaceBuild.Deadline.IsZero()) + } else { + require.WithinDuration(t, time.Now().Add(c.expectedTTL), workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected") + } + if c.expectedMaxTTL == 0 { + require.True(t, workspaceBuild.MaxDeadline.IsZero()) + } else { + require.WithinDuration(t, time.Now().Add(c.expectedMaxTTL), workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.GreaterOrEqual(t, workspaceBuild.MaxDeadline.Unix(), workspaceBuild.Deadline.Unix(), "max deadline is smaller than deadline") + } + }) + } }) t.Run("TemplateDryRun", func(t *testing.T) { @@ -989,14 +1150,15 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { pubsub := database.NewPubsubInMemory() return &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: pubsub, - Telemetry: telemetry.NewNoop(), - Auditor: mockAuditor(), + ID: uuid.New(), + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: pubsub, + Telemetry: telemetry.NewNoop(), + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), } } @@ -1006,3 +1168,17 @@ 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) +} diff --git a/coderd/autobuild/schedule/schedule.go b/coderd/schedule/cron.go similarity index 96% rename from coderd/autobuild/schedule/schedule.go rename to coderd/schedule/cron.go index d13c367a2eb09..3da7ebb4b58d9 100644 --- a/coderd/autobuild/schedule/schedule.go +++ b/coderd/schedule/cron.go @@ -1,5 +1,6 @@ -// package schedule provides utilities for parsing and deserializing -// cron-style expressions. +// package schedule provides utilities for managing template and workspace +// auto-start and auto-stop schedules. This includes utilities for parsing and +// deserializing cron-style expressions. package schedule import ( diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/schedule/cron_test.go similarity index 99% rename from coderd/autobuild/schedule/schedule_test.go rename to coderd/schedule/cron_test.go index ccba3e4f37773..9437cff4eed04 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/schedule/cron_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" ) func Test_Weekly(t *testing.T) { diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go new file mode 100644 index 0000000000000..0d328837f3cf8 --- /dev/null +++ b/coderd/schedule/template.go @@ -0,0 +1,61 @@ +package schedule + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/coder/coder/coderd/database" +) + +type TemplateScheduleOptions struct { + UserSchedulingEnabled bool `json:"user_scheduling_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. + // + // If set, users cannot disable automatic workspace shutdown. + MaxTTL time.Duration `json:"max_ttl"` +} + +// TemplateScheduleStore provides an interface for retrieving template +// scheduling options set by the template/site admin. +type TemplateScheduleStore interface { + GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) + SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error) +} + +type agplTemplateScheduleStore struct{} + +var _ TemplateScheduleStore = &agplTemplateScheduleStore{} + +func NewAGPLTemplateScheduleStore() TemplateScheduleStore { + return &agplTemplateScheduleStore{} +} + +func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { + tpl, err := db.GetTemplateByID(ctx, templateID) + if err != nil { + return TemplateScheduleOptions{}, err + } + + return TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: time.Duration(tpl.DefaultTTL), + // Disregard the value in the database, since MaxTTL is an enterprise + // feature. + MaxTTL: 0, + }, nil +} + +func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { + 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, + }) +} diff --git a/coderd/templates.go b/coderd/templates.go index c361d02417d4a..aa4a6ddc239a2 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" "github.com/coder/coder/examples" @@ -212,16 +213,31 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return } - var ttl time.Duration + var ( + defaultTTL time.Duration + maxTTL time.Duration + ) if createTemplate.DefaultTTLMillis != nil { - ttl = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond + defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond + } + if createTemplate.MaxTTLMillis != nil { + maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + } + + var validErrs []codersdk.ValidationError + if defaultTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) + } + if maxTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + } + if maxTTL != 0 && defaultTTL > maxTTL { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } - if ttl < 0 { + if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid create template request.", - Validations: []codersdk.ValidationError{ - {Field: "default_ttl_ms", Detail: "Must be a positive integer."}, - }, + Message: "Invalid create template request.", + Validations: validErrs, }) return } @@ -244,7 +260,6 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque Provisioner: importJob.Provisioner, ActiveVersionID: templateVersion.ID, Description: createTemplate.Description, - DefaultTTL: int64(ttl), CreatedBy: apiKey.UserID, UserACL: database.TemplateACL{}, GroupACL: database.TemplateACL{ @@ -258,6 +273,15 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } + dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, + }) + if err != nil { + return xerrors.Errorf("set template schedule options: %s", err) + } + templateAudit.New = dbTemplate err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ @@ -452,6 +476,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.DefaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } + if req.MaxTTLMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + } + if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + } if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -468,7 +498,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.DisplayName == template.DisplayName && req.Icon == template.Icon && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && - req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() { + req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && + req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() { return nil } @@ -479,7 +510,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { displayName := req.DisplayName desc := req.Description icon := req.Icon - maxTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs if name == "" { @@ -497,11 +527,23 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DisplayName: displayName, Description: desc, Icon: icon, - DefaultTTL: int64(maxTTL), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, }) if err != nil { - return err + return xerrors.Errorf("update template metadata: %w", err) + } + + 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) { + updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, + }) + if err != nil { + return xerrors.Errorf("set template schedule options: %w", err) + } } return nil @@ -635,6 +677,7 @@ func (api *API) convertTemplate( Description: template.Description, Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), + MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 498c44ffaca5a..195169378d99d 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "context" "net/http" + "sync/atomic" "testing" "time" @@ -15,6 +16,7 @@ import ( "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -87,7 +89,7 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("MaxTTLTooLow", func(t *testing.T) { + t.Run("DefaultTTLTooLow", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -107,7 +109,7 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "default_ttl_ms: Must be a positive integer") }) - t.Run("NoMaxTTL", func(t *testing.T) { + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -143,6 +145,95 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "Try logging in using 'coder login '.") }) + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + + const ( + defaultTTL = 1 * time.Hour + maxTTL = 24 * time.Hour + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + 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) { + atomic.AddInt64(&setCalled, 1) + require.Equal(t, maxTTL, options.MaxTTL) + template.DefaultTTL = int64(options.DefaultTTL) + template.MaxTTL = int64(options.MaxTTL) + 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, + DefaultTTLMillis: ptr.Ref(int64(0)), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + }) + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.EqualValues(t, 0, got.DefaultTTLMillis) + require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) + }) + + t.Run("DefaultTTLBigger", 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() + + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + DefaultTTLMillis: ptr.Ref((maxTTL * 2).Milliseconds()), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") + }) + + 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, + DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + }) + require.NoError(t, err) + require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) + require.Zero(t, got.MaxTTLMillis) + }) + }) + t.Run("NoVersion", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -290,7 +381,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[4].Action) }) - t.Run("NoMaxTTL", func(t *testing.T) { + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -319,7 +410,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) }) - t.Run("MaxTTLTooLow", func(t *testing.T) { + t.Run("DefaultTTLTooLow", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -345,6 +436,114 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.DefaultTTLMillis, template.DefaultTTLMillis) }) + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + + const ( + defaultTTL = 1 * time.Hour + maxTTL = 24 * time.Hour + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + 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) { + if atomic.AddInt64(&setCalled, 1) == 2 { + require.Equal(t, maxTTL, options.MaxTTL) + } + template.DefaultTTL = int64(options.DefaultTTL) + template.MaxTTL = int64(options.MaxTTL) + 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()) + }) + + 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, + DefaultTTLMillis: 0, + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.NoError(t, err) + + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.EqualValues(t, 0, got.DefaultTTLMillis) + require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) + }) + + t.Run("DefaultTTLBigger", 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() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: (maxTTL * 2).Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") + }) + + 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, + DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.NoError(t, err) + require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) + require.Zero(t, got.MaxTTLMillis) + }) + }) + t.Run("NotModified", func(t *testing.T) { t.Parallel() @@ -430,6 +629,36 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Equal(t, updated.Icon, "") }) + + t.Run("MaxTTLEnterpriseOnly", 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) + require.EqualValues(t, 0, template.MaxTTLMillis) + req := codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + MaxTTLMillis: (2 * time.Hour).Milliseconds(), + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.EqualValues(t, 0, updated.MaxTTLMillis) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.EqualValues(t, 0, template.MaxTTLMillis) + }) } func TestDeleteTemplate(t *testing.T) { diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 26176f5a0c93e..590f2df90eff1 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1139,6 +1139,7 @@ func (api *API) convertWorkspaceBuild( InitiatorUsername: initiator.Username, Job: apiJob, Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()), + MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()), Reason: codersdk.BuildReason(build.Reason), Resources: apiResources, Status: convertWorkspaceStatus(apiJob.Status, transition), diff --git a/coderd/workspaces.go b/coderd/workspaces.go index f6aabd55ffe2c..44032ec915772 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -18,12 +18,12 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/searchquery" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" @@ -360,7 +360,16 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, template.DefaultTTL) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template schedule.", + Detail: err.Error(), + }) + return + } + + dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, templateSchedule.MaxTTL) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Time to Shutdown.", @@ -798,9 +807,15 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { + templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, s, workspace.TemplateID) + if err != nil { + return xerrors.Errorf("get template schedule: %w", err) + } + + // don't override 0 ttl with template default here because it indicates + // disabled auto-stop var validityErr error - // don't override 0 ttl with template default here because it indicates disabled auto-stop - dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0) + dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL) if validityErr != nil { return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()} } @@ -905,12 +920,18 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { resp.Message = "Cannot extend workspace: " + err.Error() return err } + if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) { + code = http.StatusBadRequest + resp.Message = "Cannot extend workspace beyond max deadline." + return xerrors.New("Cannot extend workspace: deadline is beyond max deadline imposed by template") + } if _, err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: build.ID, UpdatedAt: build.UpdatedAt, ProvisionerState: build.ProvisionerState, Deadline: newDeadline, + MaxDeadline: build.MaxDeadline, }); err != nil { code = http.StatusInternalServerError resp.Message = "Failed to extend workspace deadline." @@ -1180,14 +1201,25 @@ func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 { return &millis } -func validWorkspaceTTLMillis(millis *int64, def int64) (sql.NullInt64, error) { +func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Duration) (sql.NullInt64, error) { + if templateDefault == 0 && templateMax != 0 || (templateMax > 0 && templateDefault > templateMax) { + templateDefault = templateMax + } + if ptr.NilOrZero(millis) { - if def == 0 { + if templateDefault == 0 { + if templateMax > 0 { + return sql.NullInt64{ + Int64: int64(templateMax), + Valid: true, + }, nil + } + return sql.NullInt64{}, nil } return sql.NullInt64{ - Int64: def, + Int64: int64(templateDefault), Valid: true, }, nil } @@ -1202,6 +1234,10 @@ func validWorkspaceTTLMillis(millis *int64, def int64) (sql.NullInt64, error) { return sql.NullInt64{}, errTTLMax } + if templateMax > 0 && truncated > templateMax { + return sql.NullInt64{}, xerrors.Errorf("time until shutdown must be less than or equal to the template's maximum TTL %q", templateMax.String()) + } + return sql.NullInt64{ Valid: true, Int64: int64(truncated), diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 2b1c7dfe8484c..455487ddad21d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -17,11 +17,11 @@ import ( "github.com/coder/coder/agent" "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/parameter" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -331,7 +331,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) { }) // TTL should be set by the template require.Equal(t, template.DefaultTTLMillis, templateTTL) - require.Equal(t, template.DefaultTTLMillis, template.DefaultTTLMillis, workspace.TTLMillis) + require.Equal(t, template.DefaultTTLMillis, *workspace.TTLMillis) }) t.Run("InvalidTTL", func(t *testing.T) { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7fb80303e6b56..4f5501c2907f2 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -35,6 +35,7 @@ const ( FeatureMultipleGitAuth FeatureName = "multiple_git_auth" FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" + FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -48,6 +49,7 @@ var FeatureNames = []FeatureName{ FeatureMultipleGitAuth, FeatureExternalProvisionerDaemons, FeatureAppearance, + FeatureAdvancedTemplateScheduling, } // Humanize returns the feature name in a human-readable format. diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 5427b93e2006e..f3a30bf4f94fe 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -88,6 +88,9 @@ type CreateTemplateRequest struct { // DefaultTTLMillis allows optionally specifying the default TTL // for all workspaces created from this template. DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` + // MaxTTLMillis allows optionally specifying the max lifetime for + // workspaces created from this template. + MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` // Allow users to cancel in-progress workspace jobs. // *bool as the default value is "true". diff --git a/codersdk/templates.go b/codersdk/templates.go index fed87a08cec56..f9f46e542c03a 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -28,8 +28,11 @@ type Template struct { Description string `json:"description"` Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` - CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` - CreatedByName string `json:"created_by_name"` + // MaxTTLMillis is an enterprise feature. It's value is only used if your + // license is entitled to use the advanced template scheduling feature. + MaxTTLMillis int64 `json:"max_ttl_ms"` + CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` + CreatedByName string `json:"created_by_name"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"` } @@ -75,12 +78,16 @@ type UpdateTemplateACL struct { } type UpdateTemplateMeta struct { - Name string `json:"name,omitempty" validate:"omitempty,template_name"` - DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` - AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,template_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` + // MaxTTLMillis can only be set if your license includes the advanced + // template scheduling feature. If you attempt to set this value while + // unlicensed, it will be ignored. + MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` + AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` } type TemplateExample struct { diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index ab43379c28da8..2d8a70724d1d5 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -68,6 +68,7 @@ type WorkspaceBuild struct { Reason BuildReason `db:"reason" json:"reason" enums:"initiator,autostart,autostop"` Resources []WorkspaceResource `json:"resources"` Deadline NullTime `json:"deadline,omitempty" format:"date-time"` + MaxDeadline NullTime `json:"max_deadline,omitempty" format:"date-time"` Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` DailyCost int32 `json:"daily_cost"` } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 0d376d755b377..f3950f0419174 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,17 +9,17 @@ We track the following resources: -| Resource | | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idfalse
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| Resource | | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idfalse
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
max_ttltrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| diff --git a/docs/api/builds.md b/docs/api/builds.md index 2f6ba88602508..fc94d0cc2ceeb 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -49,6 +49,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -197,6 +198,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -720,6 +722,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -873,6 +876,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -999,6 +1003,7 @@ Status Code **200** | `»» tags` | object | false | | | | `»»» [any property]` | string | false | | | | `»» worker_id` | string(uuid) | false | | | +| `» max_deadline` | string(date-time) | false | | | | `» reason` | [codersdk.BuildReason](schemas.md#codersdkbuildreason) | false | | | | `» resources` | array | false | | | | `»» agents` | array | false | | | @@ -1197,6 +1202,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 9a807e4e17a4f..ed8c57646290d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -977,6 +977,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "description": "string", "display_name": "string", "icon": "string", + "max_ttl_ms": 0, "name": "string", "parameter_values": [ { @@ -1000,6 +1001,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `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. | @@ -4583,6 +4585,7 @@ Parameter represents a set value for the scope. "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -4592,24 +4595,25 @@ 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 | | | -| `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_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 @@ -5308,6 +5312,7 @@ Parameter represents a set value for the scope. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -5776,6 +5781,7 @@ Parameter represents a set value for the scope. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -5881,6 +5887,7 @@ Parameter represents a set value for the scope. | `initiator_id` | string | false | | | | `initiator_name` | string | false | | | | `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | | +| `max_deadline` | string | false | | | | `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | | `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | | | `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | | @@ -6144,6 +6151,7 @@ Parameter represents a set value for the scope. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { diff --git a/docs/api/templates.md b/docs/api/templates.md index 5160210ae1792..66b16176dd001 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -118,6 +118,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -136,28 +137,29 @@ 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 | | | -| `» 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_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 @@ -190,6 +192,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "description": "string", "display_name": "string", "icon": "string", + "max_ttl_ms": 0, "name": "string", "parameter_values": [ { @@ -238,6 +241,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -360,6 +364,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -681,6 +686,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -786,6 +792,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index ebfb9cab84e61..40e7f96af73ae 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -81,6 +81,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -248,6 +249,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -438,6 +440,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -602,6 +605,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { diff --git a/docs/cli/coder_templates_edit.md b/docs/cli/coder_templates_edit.md index e9f59bc9c26bb..60a8b18c6b804 100644 --- a/docs/cli/coder_templates_edit.md +++ b/docs/cli/coder_templates_edit.md @@ -22,7 +22,7 @@ Allow users to cancel in-progress workspace jobs. ### --default-ttl -Edit the template default time before shutdown - workspaces created from this template to this value. +Edit the template default time before shutdown - workspaces created from this template default to this value.
| | | | --- | --- | @@ -30,28 +30,36 @@ Edit the template default time before shutdown - workspaces created from this te ### --description -Edit the template description +Edit the template description.
| | | | --- | --- | ### --display-name -Edit the template display name +Edit the template display name.
| | | | --- | --- | ### --icon -Edit the template icon path +Edit the template icon path.
| | | | --- | --- | +### --max-ttl + +Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature. +
+| | | +| --- | --- | +| Default | 0s | + ### --name -Edit the template name +Edit the template name.
| | | | --- | --- | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 846692d583b82..442a8b0354b98 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -70,6 +70,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "group_acl": ActionTrack, "user_acl": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, + "max_ttl": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 30e4ac8182e24..8ddfb0bbd98d2 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/enterprise/derpmesh" @@ -252,6 +253,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, + codersdk.FeatureAdvancedTemplateScheduling: true, }) if err != nil { return err @@ -310,6 +312,17 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } + if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed { + if enabled { + store := &enterpriseTemplateScheduleStore{} + ptr := schedule.TemplateScheduleStore(store) + api.AGPL.TemplateScheduleStore.Store(&ptr) + } else { + store := schedule.NewAGPLTemplateScheduleStore() + api.AGPL.TemplateScheduleStore.Store(&store) + } + } + if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { coordinator := agpltailnet.NewCoordinator() if enabled { diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index f8141b620a29e..44de1d806fd06 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -52,6 +52,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAuditLog: 1, codersdk.FeatureTemplateRBAC: 1, codersdk.FeatureExternalProvisionerDaemons: 1, + codersdk.FeatureAdvancedTemplateScheduling: 1, }, }) res, err := client.Entitlements(context.Background()) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 057579fcfee8f..a130bbeeb1c24 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/hashicorp/yamux" @@ -26,6 +27,7 @@ import ( "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd/proto" ) @@ -216,15 +218,16 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) } mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - Telemetry: api.Telemetry, - Auditor: &api.AGPL.Auditor, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), - Tags: rawTags, + AccessURL: api.AccessURL, + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + Telemetry: api.Telemetry, + Auditor: &api.AGPL.Auditor, + TemplateScheduleStore: &api.AGPL.TemplateScheduleStore, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + Tags: rawTags, }) if err != nil { _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) @@ -301,3 +304,55 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock Conn: nc, } } + +type enterpriseTemplateScheduleStore struct{} + +var _ schedule.TemplateScheduleStore = &enterpriseTemplateScheduleStore{} + +func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { + tpl, err := db.GetTemplateByID(ctx, templateID) + if err != nil { + return schedule.TemplateScheduleOptions{}, err + } + + return schedule.TemplateScheduleOptions{ + // TODO: make configurable at template level + UserSchedulingEnabled: true, + 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) { + template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: tpl.ID, + UpdatedAt: database.Now(), + DefaultTTL: int64(opts.DefaultTTL), + MaxTTL: int64(opts.MaxTTL), + }) + if err != nil { + return database.Template{}, xerrors.Errorf("update template schedule: %w", err) + } + + // Update all workspaces using the template to set the user defined schedule + // to be within the new bounds. This essentially does the following for each + // workspace using the template. + // if (template.ttl != NULL) { + // workspace.ttl = min(workspace.ttl, template.ttl) + // } + // + // NOTE: this does not apply to currently running workspaces as their + // schedule information is committed to the workspace_build during start. + // This limitation is displayed to the user while editing the template. + if opts.MaxTTL > 0 { + err = db.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams{ + TemplateID: template.ID, + TemplateMaxTTL: int64(opts.MaxTTL), + }) + if err != nil { + return database.Template{}, xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err) + } + } + + return template, nil +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 7c9303f188310..7c58bd9527514 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -21,6 +22,192 @@ import ( "github.com/coder/coder/testutil" ) +func TestTemplates(t *testing.T) { + t.Parallel() + + t.Run("SetMaxTTL", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.EqualValues(t, 0, template.MaxTTLMillis) + + // Create some workspaces to test propagation to user-defined TTLs. + workspace1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + ttl := (24 * time.Hour).Milliseconds() + cwr.TTLMillis = &ttl + }) + workspace2TTL := (1 * time.Hour).Milliseconds() + workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = &workspace2TTL + }) + workspace3 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + // To unset TTL you have to update, as setting a nil TTL on create + // copies the template default TTL. + ctx, _ := testutil.Context(t) + err := client.UpdateWorkspaceTTL(ctx, workspace3.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: nil, + }) + require.NoError(t, err) + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + MaxTTLMillis: (2 * time.Hour).Milliseconds(), + }) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, time.Duration(updated.MaxTTLMillis)*time.Millisecond) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, time.Duration(template.MaxTTLMillis)*time.Millisecond) + + // Verify that only the first workspace has been updated. + workspace1, err = client.Workspace(ctx, workspace1.ID) + require.NoError(t, err) + require.Equal(t, &template.MaxTTLMillis, workspace1.TTLMillis) + + workspace2, err = client.Workspace(ctx, workspace2.ID) + require.NoError(t, err) + require.Equal(t, &workspace2TTL, workspace2.TTLMillis) + + workspace3, err = client.Workspace(ctx, workspace3.ID) + require.NoError(t, err) + require.Nil(t, workspace3.TTLMillis) + }) + + t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + exp := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = &exp + ctr.MaxTTLMillis = &exp + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // No TTL provided should use template default + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + } + ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to have a higher TTL than the template's max + // should error + exp = exp + time.Minute.Milliseconds() + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: &exp, + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") + + // Creating workspace with TTL higher than max should error + req.Name = "testing2" + req.TTLMillis = &exp + ws, err = client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.Error(t, err) + apiErr = nil + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") + }) + + t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + exp := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MaxTTLMillis = &exp + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // No TTL provided should use template default + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + } + ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to disable the TTL should do nothing + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: nil, + }) + require.NoError(t, err) + ws, err = client.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to have a TTL of 0 should do nothing + zero := int64(0) + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: &zero, + }) + require.NoError(t, err) + ws, err = client.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + }) +} + func TestTemplateACL(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d75b733fe103a..be15f72634681 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -186,6 +186,7 @@ export interface CreateTemplateRequest { readonly template_version_id: string readonly parameter_values?: CreateParameterRequest[] readonly default_ttl_ms?: number + readonly max_ttl_ms?: number readonly allow_user_cancel_workspace_jobs?: boolean } @@ -719,6 +720,7 @@ export interface Template { readonly description: string readonly icon: string readonly default_ttl_ms: number + readonly max_ttl_ms: number readonly created_by_id: string readonly created_by_name: string readonly allow_user_cancel_workspace_jobs: boolean @@ -878,6 +880,7 @@ export interface UpdateTemplateMeta { readonly description?: string readonly icon?: string readonly default_ttl_ms?: number + readonly max_ttl_ms?: number readonly allow_user_cancel_workspace_jobs?: boolean } @@ -1044,6 +1047,7 @@ export interface WorkspaceBuild { readonly reason: BuildReason readonly resources: WorkspaceResource[] readonly deadline?: string + readonly max_deadline?: string readonly status: WorkspaceStatus readonly daily_cost: number } @@ -1154,6 +1158,7 @@ export const Experiments: Experiment[] = ["authz_querier", "template_editor"] // From codersdk/deployment.go export type FeatureName = + | "advanced_template_scheduling" | "appearance" | "audit_log" | "browser_only" @@ -1164,6 +1169,7 @@ export type FeatureName = | "template_rbac" | "user_limit" export const FeatureNames: FeatureName[] = [ + "advanced_template_scheduling", "appearance", "audit_log", "browser_only", diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index f1b4116b244b5..61d268b5ba8cd 100644 --- a/site/src/i18n/en/common.json +++ b/site/src/i18n/en/common.json @@ -39,5 +39,7 @@ "updateCheck": { "message": "Coder {{version}} is now available. View the <4>release notes and <7>upgrade instructions for more information.", "error": "Coder update check failed." - } + }, + "licenseFieldTextHelper": "You need an enterprise license to use it.", + "learnMore": "Learn more" } diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 575c492aa0fe3..567e17ca60dbd 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -26,11 +26,17 @@ "displayName": "Display name", "description": "Description", "icon": "Icon", - "autoStop": "Auto-stop default", + "autoStop": "Default auto-stop", + "maxTTL": "Max. Lifetime (alpha)", "allowUsersToCancel": "Allow users to cancel in-progress workspace jobs" }, "helperText": { - "autoStop": "Time in hours", + "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.", + "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.", "allowUsersToCancel": "If checked, users may be able to corrupt their workspace." }, "upload": { @@ -39,6 +45,13 @@ }, "tooltip": { "allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases." + }, + "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.", + "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." } } } diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index f738567b50756..bd0f35f42711d 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -4,15 +4,21 @@ "displayNameLabel": "Display name", "descriptionLabel": "Description", "descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.", - "defaultTtlLabel": "Auto-stop default", + "defaultTtlLabel": "Default auto-stop", + "maxTtlLabel": "Max. Lifetime (alpha)", "iconLabel": "Icon", "formAriaLabel": "Template settings form", "selectEmoji": "Select emoji", - "ttlMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).", - "ttlMinError": "Default time until auto-stop must not be less than 0.", - "ttlHelperText_zero": "Workspaces created from this template will run until stopped manually.", - "ttlHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.", - "ttlHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.", + "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.", + "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.", + "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.", "allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.", "allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.", "allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.", @@ -26,7 +32,7 @@ }, "schedule": { "title": "Schedule", - "description": "Define when workspaces created from this template automatically stop." + "description": "Define when workspaces created from this template are stopped." }, "operations": { "title": "Operations", diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 5e0a8043e1cbe..f1af33a9d38cf 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -28,19 +28,70 @@ import * as Yup from "yup" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { LazyIconField } from "components/IconField/LazyIconField" -import { VariableInput } from "./VariableInput" +import { Maybe } from "components/Conditionals/Maybe" +import i18next from "i18next" +import Link from "@material-ui/core/Link" +import { FormFooter } from "components/FormFooter/FormFooter" import { - FormFields, - FormFooter, - FormSection, HorizontalForm, + FormSection, + FormFields, } from "components/HorizontalForm/HorizontalForm" import camelCase from "lodash/camelCase" import capitalize from "lodash/capitalize" +import { VariableInput } from "./VariableInput" + +const MAX_DESCRIPTION_CHAR_LIMIT = 128 +const MAX_TTL_DAYS = 7 + +const TTLHelperText = ({ + ttl, + translationName, +}: { + ttl?: number + translationName: string +}) => { + const { t } = useTranslation("createTemplatePage") + const count = typeof ttl !== "number" ? 0 : ttl + return ( + // no helper text if ttl is negative - error will show once field is considered touched + = 0}> + {t(translationName, { count })} + + ) +} const validationSchema = Yup.object({ - name: nameValidator("Name"), - display_name: templateDisplayNameValidator("Display name"), + name: nameValidator( + i18next.t("form.fields.name", { ns: "createTemplatePage" }), + ), + display_name: templateDisplayNameValidator( + i18next.t("form.fields.displayName", { + ns: "createTemplatePage", + }), + ), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + i18next.t("form.error.descriptionMax", { ns: "createTemplatePage" }), + ), + icon: Yup.string().optional(), + default_ttl_hours: Yup.number() + .integer() + .min( + 0, + i18next.t("form.error.defaultTTLMin", { ns: "templateSettingsPage" }), + ) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("form.error.defaultTTLMax", { ns: "templateSettingsPage" }), + ), + max_ttl_hours: Yup.number() + .integer() + .min(0, i18next.t("form.error.maxTTLMin", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("form.error.maxTTLMax", { ns: "templateSettingsPage" }), + ), }) const defaultInitialValues: CreateTemplateData = { @@ -49,16 +100,29 @@ const defaultInitialValues: CreateTemplateData = { description: "", icon: "", default_ttl_hours: 24, + // max_ttl is an enterprise-only feature, and the server ignores the value if + // you are not licensed. We hide the form value based on entitlements. + max_ttl_hours: 24 * 7, allow_user_cancel_workspace_jobs: false, } -const getInitialValues = (starterTemplate?: TemplateExample) => { +const getInitialValues = ( + canSetMaxTTL: boolean, + starterTemplate?: TemplateExample, +) => { + let initialValues = defaultInitialValues + if (!canSetMaxTTL) { + initialValues = { + ...initialValues, + max_ttl_hours: 0, + } + } if (!starterTemplate) { - return defaultInitialValues + return initialValues } return { - ...defaultInitialValues, + ...initialValues, name: starterTemplate.id, display_name: starterTemplate.name, icon: starterTemplate.icon, @@ -77,6 +141,7 @@ export interface CreateTemplateFormProps { error?: unknown jobError?: string logs?: ProvisionerJobLog[] + canSetMaxTTL: boolean } export const CreateTemplateForm: FC = ({ @@ -90,15 +155,17 @@ export const CreateTemplateForm: FC = ({ error, jobError, logs, + canSetMaxTTL, }) => { const styles = useStyles() const form = useFormik({ - initialValues: getInitialValues(starterTemplate), + initialValues: getInitialValues(canSetMaxTTL, starterTemplate), validationSchema, onSubmit, }) const getFieldHelpers = getFormHelpers(form, error) const { t } = useTranslation("createTemplatePage") + const { t: commonT } = useTranslation("common") return ( @@ -175,16 +242,48 @@ export const CreateTemplateForm: FC = ({ description={t("form.schedule.description")} > - + + , + )} + disabled={isSubmitting} + onChange={onChangeTrimmed(form)} + fullWidth + label={t("form.fields.autoStop")} + variant="outlined" + type="number" + /> + + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !canSetMaxTTL} + fullWidth + label={t("form.fields.maxTTL")} + variant="outlined" + type="number" + /> + @@ -318,6 +417,10 @@ const fillNameAndDisplayWithFilename = async ( } const useStyles = makeStyles((theme) => ({ + ttlFields: { + width: "100%", + }, + optionText: { fontSize: theme.spacing(2), color: theme.palette.text.primary, diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index dabe9628feb4b..4692bbd346b02 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -2,6 +2,7 @@ import { useMachine } from "@xstate/react" import { isApiValidationError } from "api/errors" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" +import { useDashboard } from "components/Dashboard/DashboardProvider" import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" import { Loader } from "components/Loader/Loader" import { Stack } from "components/Stack/Stack" @@ -40,6 +41,9 @@ const CreateTemplatePage: FC = () => { variables, } = state.context const shouldDisplayForm = !state.hasTag("loading") + const { entitlements } = useDashboard() + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled const onCancel = () => { navigate(-1) @@ -63,6 +67,7 @@ const CreateTemplatePage: FC = () => { {shouldDisplayForm && ( { +const TTLHelperText = ({ + ttl, + translationName, +}: { + ttl?: number + translationName: string +}) => { const { t } = useTranslation("templateSettingsPage") const count = typeof ttl !== "number" ? 0 : ttl return ( // no helper text if ttl is negative - error will show once field is considered touched = 0}> - {t("ttlHelperText", { count })} + {t(translationName, { count })} ) } @@ -53,10 +60,17 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => ), default_ttl_ms: Yup.number() .integer() - .min(0, i18next.t("ttlMinError", { ns: "templateSettingsPage" })) + .min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }), + ), + max_ttl_ms: Yup.number() + .integer() + .min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" })) .max( 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("ttlMaxError", { ns: "templateSettingsPage" }), + i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), ), allow_user_cancel_workspace_jobs: Yup.boolean(), }) @@ -67,6 +81,7 @@ export interface TemplateSettingsForm { onCancel: () => void isSubmitting: boolean error?: unknown + canSetMaxTTL: boolean // Helpful to show field errors on Storybook initialTouched?: FormikTouched } @@ -76,9 +91,11 @@ export const TemplateSettingsForm: FC = ({ onSubmit, onCancel, error, + canSetMaxTTL, isSubmitting, initialTouched, }) => { + const { t: commonT } = useTranslation("common") const validationSchema = getValidationSchema() const form: FormikContextType = useFormik({ @@ -88,6 +105,9 @@ export const TemplateSettingsForm: FC = ({ description: template.description, // on display, convert from ms => hours default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, + // the API ignores this value, but to avoid tripping up validation set + // it to zero if the user can't set the field. + max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0, icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, @@ -100,6 +120,9 @@ export const TemplateSettingsForm: FC = ({ default_ttl_ms: formData.default_ttl_ms ? formData.default_ttl_ms * MS_HOUR_CONVERSION : undefined, + max_ttl_ms: formData.max_ttl_ms + ? formData.max_ttl_ms * MS_HOUR_CONVERSION + : undefined, }) }, initialTouched, @@ -169,18 +192,49 @@ export const TemplateSettingsForm: FC = ({ title={t("schedule.title")} description={t("schedule.description")} > - , - )} - disabled={isSubmitting} - fullWidth - inputProps={{ min: 0, step: 1 }} - label={t("defaultTtlLabel")} - variant="outlined" - type="number" - /> + + , + )} + disabled={isSubmitting} + fullWidth + inputProps={{ min: 0, step: 1 }} + label={t("defaultTtlLabel")} + variant="outlined" + type="number" + /> + + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !canSetMaxTTL} + fullWidth + inputProps={{ min: 0, step: 1 }} + label={t("maxTtlLabel")} + variant="outlined" + type="number" + /> + ({ fontSize: theme.spacing(1.5), color: theme.palette.text.secondary, }, + + ttlFields: { + width: "100%", + }, })) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx index fba091e4d7c63..52b601a7c725e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -16,7 +16,9 @@ const validFormValues = { display_name: "A display name", description: "A description", icon: "vscode.png", + // these are the form values which are actually hours default_ttl_ms: 1, + max_ttl_ms: 2, allow_user_cancel_workspace_jobs: false, } @@ -36,6 +38,7 @@ const fillAndSubmitForm = async ({ display_name, description, default_ttl_ms, + max_ttl_ms, icon, allow_user_cancel_workspace_jobs, }: Required) => { @@ -61,9 +64,17 @@ const fillAndSubmitForm = async ({ await userEvent.type(iconField, icon) const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) - const maxTtlField = await screen.findByLabelText(defaultTtlLabel) - await userEvent.clear(maxTtlField) - await userEvent.type(maxTtlField, default_ttl_ms.toString()) + const defaultTtlField = await screen.findByLabelText(defaultTtlLabel) + await userEvent.clear(defaultTtlField) + await userEvent.type(defaultTtlField, default_ttl_ms.toString()) + + const entitlements = await API.getEntitlements() + if (entitlements.features["advanced_template_scheduling"].enabled) { + const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" }) + const maxTtlField = await screen.findByLabelText(maxTtlLabel) + await userEvent.clear(maxTtlField) + await userEvent.type(maxTtlField, max_ttl_ms.toString()) + } const allowCancelJobsField = screen.getByRole("checkbox") // checkbox is checked by default, so it must be clicked to get unchecked @@ -110,12 +121,17 @@ describe("TemplateSettingsPage", () => { await fillAndSubmitForm(validFormValues) await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) - expect(API.updateTemplateMeta).toBeCalledWith( - "test-template", - expect.objectContaining({ - ...validFormValues, - default_ttl_ms: 3600000, // the default_ttl_ms to ms - }), + await waitFor(() => + expect(API.updateTemplateMeta).toBeCalledWith( + "test-template", + expect.objectContaining({ + ...validFormValues, + // convert from the display value (hours) to ms + default_ttl_ms: validFormValues.default_ttl_ms * 3600000, + // this value is undefined if not entitled + max_ttl_ms: undefined, + }), + ), ) }) @@ -144,7 +160,7 @@ describe("TemplateSettingsPage", () => { } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( - t("ttlMaxError", { ns: "templateSettingsPage" }), + t("defaultTTLMaxError", { ns: "templateSettingsPage" }), ) }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx index b00a56ac569d4..9fb935d731bab 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx @@ -1,4 +1,5 @@ import { useMachine } from "@xstate/react" +import { useDashboard } from "components/Dashboard/DashboardProvider" import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" @@ -27,6 +28,9 @@ export const TemplateSettingsPage: FC = () => { saveTemplateSettingsError, getTemplateError, } = state.context + const { entitlements } = useDashboard() + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled return ( <> @@ -34,6 +38,7 @@ export const TemplateSettingsPage: FC = () => { {pageTitle(t("title"))} = (args) => ( @@ -17,10 +23,11 @@ const Template: Story = (args) => ( ) export const Example = Template.bind({}) -Example.args = { - template: Mocks.MockTemplate, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), +Example.args = {} + +export const CantSetMaxTTL = Template.bind({}) +CantSetMaxTTL.args = { + canSetMaxTTL: false, } export const GetTemplateError = Template.bind({}) @@ -32,13 +39,10 @@ GetTemplateError.args = { detail: "You do not have permission to access this resource.", }), }, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), } export const SaveTemplateSettingsError = Template.bind({}) SaveTemplateSettingsError.args = { - template: Mocks.MockTemplate, errors: { saveTemplateSettingsError: makeMockApiError({ message: 'Template "test" already exists.', @@ -53,6 +57,4 @@ SaveTemplateSettingsError.args = { initialTouched: { allow_user_cancel_workspace_jobs: true, }, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx index 6c514e1470b9a..14eec2798bb95 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx @@ -18,6 +18,7 @@ export interface TemplateSettingsPageViewProps { saveTemplateSettingsError?: unknown } initialTouched?: ComponentProps["initialTouched"] + canSetMaxTTL: boolean } export const TemplateSettingsPageView: FC = ({ @@ -25,6 +26,7 @@ export const TemplateSettingsPageView: FC = ({ onCancel, onSubmit, isSubmitting, + canSetMaxTTL, errors = {}, initialTouched, }) => { @@ -43,6 +45,7 @@ export const TemplateSettingsPageView: FC = ({ {template && ( <> user_variable_values?: VariableValue[] @@ -413,6 +414,7 @@ export const createTemplateMachine = const { default_ttl_hours, + max_ttl_hours, parameter_values_by_name, ...safeTemplateData } = templateData @@ -420,6 +422,7 @@ export const createTemplateMachine = return createTemplate(organizationId, { ...safeTemplateData, default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms + max_ttl_ms: templateData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms template_version_id: version.id, }) },