diff --git a/cli/server.go b/cli/server.go index b6fa7c31b647c..7d4261a2e2a7f 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 e0aa6bf694fd3..c4c6e3fd27615 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 1a97401c958e4..8fdaf1835b134 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -428,6 +428,147 @@ func TestTemplateEdit(t *testing.T) { 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) + }) + }) + 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) @@ -437,6 +578,98 @@ func TestTemplateEdit(t *testing.T) { 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 0dc5d8d6a7d69..271f0d9b9ed56 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]