Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: allow disabling user scheduling on template create
  • Loading branch information
deansheather committed Apr 3, 2023
commit b319b686d60e53a0ce20515c555220371768d842
22 changes: 19 additions & 3 deletions cli/templateedit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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")
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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(),
}

Expand Down
233 changes: 233 additions & 0 deletions cli/templateedit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
})
})
}
8 changes: 8 additions & 0 deletions cli/testdata/coder_templates_edit_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ Usage: coder templates edit [flags] <template>
Edit the metadata of a template by name.

Options
--allow-user-autostart bool (default: true)
Allow users to configure autostart for workspaces on this template.
This can only be disabled in enterprise.

--allow-user-autostop bool (default: true)
Allow users to customize the autostop TTL for workspaces on this
template. This can only be disabled in enterprise.

--allow-user-cancel-workspace-jobs bool (default: true)
Allow users to cancel in-progress workspace jobs.

Expand Down
8 changes: 8 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/database/dbfake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -2850,6 +2850,8 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl
DisplayName: arg.DisplayName,
Icon: arg.Icon,
AllowUserCancelWorkspaceJobs: arg.AllowUserCancelWorkspaceJobs,
AllowUserAutoStart: true,
AllowUserAutoStop: true,
}
q.templates = append(q.templates, template)
return template.DeepCopy(), nil
Expand Down
5 changes: 5 additions & 0 deletions coderd/schedule/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context
}

func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) {
if int64(opts.DefaultTTL) == tpl.DefaultTTL {
// Avoid updating the UpdatedAt timestamp if nothing will be changed.
return tpl, nil
}

return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: tpl.ID,
UpdatedAt: database.Now(),
Expand Down
Loading