From e33941b7c2b66c1c115bab3914d4518724aa8bdc Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 4 Apr 2023 22:48:35 +1000 Subject: [PATCH] feat: allow disabling autostart and custom autostop for template (#6933) API only, frontend in upcoming PR. --- cli/server.go | 5 +- cli/templateedit.go | 22 +- cli/templateedit_test.go | 235 +++++++++++++++++- .../coder_templates_edit_--help.golden | 8 + coderd/activitybump_test.go | 33 +-- coderd/apidoc/docs.go | 15 ++ coderd/apidoc/swagger.json | 15 ++ .../autobuild/executor/lifecycle_executor.go | 68 ++--- .../executor/lifecycle_executor_test.go | 57 ++++- coderd/coderd.go | 15 +- coderd/coderdtest/coderdtest.go | 10 +- coderd/database/dbauthz/system.go | 4 + coderd/database/dbfake/databasefake.go | 29 +++ coderd/database/dump.sql | 10 +- .../000073_remove_min_autostart.up.sql | 2 +- ..._template_disable_user_scheduling.down.sql | 3 + ...13_template_disable_user_scheduling.up.sql | 9 + coderd/database/modelqueries.go | 2 + coderd/database/models.go | 6 +- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 125 ++++++++-- coderd/database/queries/templates.sql | 6 +- coderd/database/queries/workspaces.sql | 39 +++ .../provisionerdserver/provisionerdserver.go | 8 +- .../provisionerdserver_test.go | 173 ++++++------- coderd/schedule/cron.go | 2 +- coderd/schedule/mock.go | 32 +++ coderd/schedule/template.go | 21 +- coderd/templates.go | 41 ++- coderd/templates_test.go | 169 ++++++++++++- coderd/workspaces.go | 22 +- coderd/workspaces_test.go | 94 +++++++ codersdk/organizations.go | 11 + codersdk/templates.go | 7 + docs/admin/audit-logs.md | 22 +- docs/api/schemas.md | 68 ++--- docs/api/templates.md | 60 +++-- docs/cli/templates_edit.md | 18 ++ docs/enterprise.md | 24 +- docs/images/{auto-start.png => autostart.png} | Bin docs/images/{auto-stop.png => autostop.png} | Bin docs/templates.md | 2 +- docs/workspaces.md | 14 +- enterprise/audit/table.go | 2 + enterprise/coderd/provisionerdaemons.go | 28 ++- site/src/api/typesGenerated.ts | 6 + .../WorkspaceSchedule/WorkspaceSchedule.tsx | 12 +- .../WorkspaceScheduleLabel.tsx | 12 +- .../WorkspaceScheduleForm.stories.tsx | 16 +- .../WorkspaceScheduleForm.test.ts | 12 +- .../WorkspaceScheduleForm.tsx | 62 ++--- site/src/i18n/en/common.json | 4 +- site/src/i18n/en/createTemplatePage.json | 6 +- site/src/i18n/en/templateForm.json | 4 +- site/src/i18n/en/templateSettingsPage.json | 6 +- site/src/i18n/en/workspaceSchedulePage.json | 2 +- .../CreateTemplatePage/CreateTemplateForm.tsx | 2 +- .../WorkspaceSchedulePage.test.tsx | 92 +++---- .../WorkspaceSchedulePage.tsx | 24 +- .../WorkspaceSchedulePage/formToRequest.ts | 6 +- .../pages/WorkspaceSchedulePage/schedule.ts | 16 +- site/src/pages/WorkspaceSchedulePage/ttl.ts | 10 +- site/src/util/schedule.test.ts | 52 ++-- site/src/util/schedule.ts | 14 +- .../workspaceScheduleXService.ts | 24 +- 65 files changed, 1433 insertions(+), 486 deletions(-) create mode 100644 coderd/database/migrations/000113_template_disable_user_scheduling.down.sql create mode 100644 coderd/database/migrations/000113_template_disable_user_scheduling.up.sql create mode 100644 coderd/schedule/mock.go rename docs/images/{auto-start.png => autostart.png} (100%) rename docs/images/{auto-stop.png => autostop.png} (100%) diff --git a/cli/server.go b/cli/server.go index b6fa7c31b6..7d4261a2e2 100644 --- a/cli/server.go +++ b/cli/server.go @@ -30,6 +30,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -72,6 +73,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/prometheusmetrics" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/updatecheck" @@ -632,6 +634,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. LoginRateLimit: loginRateLimit, FilesRateLimit: filesRateLimit, HTTPClient: httpClient, + TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, @@ -1019,7 +1022,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. autobuildPoller := time.NewTicker(cfg.AutobuildPollInterval.Value()) defer autobuildPoller.Stop() - autobuildExecutor := executor.New(ctx, options.Database, logger, autobuildPoller.C) + autobuildExecutor := executor.New(ctx, options.Database, coderAPI.TemplateScheduleStore, logger, autobuildPoller.C) autobuildExecutor.Run() // Currently there is no way to ask the server to shut diff --git a/cli/templateedit.go b/cli/templateedit.go index e0aa6bf694..c4c6e3fd27 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -21,6 +21,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { defaultTTL time.Duration maxTTL time.Duration allowUserCancelWorkspaceJobs bool + allowUserAutostart bool + allowUserAutostop bool ) client := new(codersdk.Client) @@ -32,17 +34,17 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { ), Short: "Edit the metadata of a template by name.", Handler: func(inv *clibase.Invocation) error { - if maxTTL != 0 { + if maxTTL != 0 || !allowUserAutostart || !allowUserAutostop { entitlements, err := client.Entitlements(inv.Context()) var sdkErr *codersdk.Error if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl") + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl, --allow-user-autostart=false or --allow-user-autostop=false") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { - return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl") + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl, --allow-user-autostart=false or --allow-user-autostop=false") } } @@ -64,6 +66,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { DefaultTTLMillis: defaultTTL.Milliseconds(), MaxTTLMillis: maxTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, + AllowUserAutostart: allowUserAutostart, + AllowUserAutostop: allowUserAutostop, } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) @@ -112,6 +116,18 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Default: "true", Value: clibase.BoolOf(&allowUserCancelWorkspaceJobs), }, + { + Flag: "allow-user-autostart", + Description: "Allow users to configure autostart for workspaces on this template. This can only be disabled in enterprise.", + Default: "true", + Value: clibase.BoolOf(&allowUserAutostart), + }, + { + Flag: "allow-user-autostop", + Description: "Allow users to customize the autostop TTL for workspaces on this template. This can only be disabled in enterprise.", + Default: "true", + Value: clibase.BoolOf(&allowUserAutostop), + }, cliui.SkipPromptOption(), } diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 1a97401c95..8fdaf1835b 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -428,7 +428,8 @@ func TestTemplateEdit(t *testing.T) { require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled)) - // Assert that the template metadata did not change. + // Assert that the template metadata did not change. We verify the + // correct request gets sent to the server already. updated, err := client.Template(context.Background(), template.ID) require.NoError(t, err) assert.Equal(t, template.Name, updated.Name) @@ -439,4 +440,236 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) }) }) + t.Run("AllowUserScheduling", func(t *testing.T) { + t.Parallel() + t.Run("BlockedAGPL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Test the cli command with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostart=false", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + + // Test the cli command with --allow-user-autostop. + cmdArgs = []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostop=false", + } + inv, root = clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx = testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) + assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) + }) + + t.Run("BlockedNotEntitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Make a proxy server that will return a valid entitlements + // response, but without advanced scheduling entitlement. + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + } + for _, feature := range codersdk.FeatureNames { + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: false, + Limit: nil, + Actual: nil, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + + // Otherwise, proxy the request to the real API server. + httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostart=false", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "license is not entitled") + + // Test the cli command with --allow-user-autostop. + cmdArgs = []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostop=false", + } + inv, root = clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx = testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "license is not entitled") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) + assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) + }) + t.Run("Entitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Make a proxy server that will return a valid entitlements + // response, including a valid advanced scheduling entitlement. + var updateTemplateCalled int64 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + } + for _, feature := range codersdk.FeatureNames { + var one int64 = 1 + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: true, + Limit: &one, + Actual: &one, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + _ = r.Body.Close() + + var req codersdk.UpdateTemplateMeta + err = json.Unmarshal(body, &req) + require.NoError(t, err) + assert.False(t, req.AllowUserAutostart) + assert.False(t, req.AllowUserAutostop) + + r.Body = io.NopCloser(bytes.NewReader(body)) + atomic.AddInt64(&updateTemplateCalled, 1) + // We still want to call the real route. + } + + // Otherwise, proxy the request to the real API server. + httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--allow-user-autostart=false", + "--allow-user-autostop=false", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled)) + + // Assert that the template metadata did not change. We verify the + // correct request gets sent to the server already. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) + assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) + }) + }) } diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index 0dc5d8d6a7..271f0d9b9e 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -3,6 +3,14 @@ Usage: coder templates edit [flags]