Skip to content

Commit 96fd840

Browse files
committed
working CLI
1 parent ad64806 commit 96fd840

File tree

7 files changed

+348
-27
lines changed

7 files changed

+348
-27
lines changed

cli/templateedit.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"fmt"
5+
"net/http"
56
"time"
67

78
"github.com/spf13/cobra"
@@ -18,6 +19,7 @@ func templateEdit() *cobra.Command {
1819
description string
1920
icon string
2021
defaultTTL time.Duration
22+
maxTTL time.Duration
2123
allowUserCancelWorkspaceJobs bool
2224
)
2325

@@ -30,6 +32,21 @@ func templateEdit() *cobra.Command {
3032
if err != nil {
3133
return xerrors.Errorf("create client: %w", err)
3234
}
35+
36+
if maxTTL != 0 {
37+
entitlements, err := client.Entitlements(cmd.Context())
38+
var sdkErr *codersdk.Error
39+
if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
40+
return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl")
41+
} else if err != nil {
42+
return xerrors.Errorf("get entitlements: %w", err)
43+
}
44+
45+
if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled {
46+
return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl")
47+
}
48+
}
49+
3350
organization, err := CurrentOrganization(cmd, client)
3451
if err != nil {
3552
return xerrors.Errorf("get current organization: %w", err)
@@ -46,6 +63,7 @@ func templateEdit() *cobra.Command {
4663
Description: description,
4764
Icon: icon,
4865
DefaultTTLMillis: defaultTTL.Milliseconds(),
66+
MaxTTLMillis: maxTTL.Milliseconds(),
4967
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
5068
}
5169

@@ -58,11 +76,12 @@ func templateEdit() *cobra.Command {
5876
},
5977
}
6078

61-
cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name")
62-
cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name")
63-
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
64-
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path")
65-
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.")
79+
cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name.")
80+
cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name.")
81+
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description.")
82+
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path.")
83+
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template default to this value.")
84+
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.")
6685
cmd.Flags().BoolVarP(&allowUserCancelWorkspaceJobs, "allow-user-cancel-workspace-jobs", "", true, "Allow users to cancel in-progress workspace jobs.")
6786
cliui.AllowSkipPrompt(cmd)
6887

cli/templateedit_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
package cli_test
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"net/http/httputil"
11+
"net/url"
512
"strconv"
13+
"strings"
14+
"sync/atomic"
615
"testing"
716
"time"
817

@@ -11,6 +20,7 @@ import (
1120

1221
"github.com/coder/coder/cli/clitest"
1322
"github.com/coder/coder/coderd/coderdtest"
23+
"github.com/coder/coder/coderd/httpapi"
1424
"github.com/coder/coder/codersdk"
1525
"github.com/coder/coder/testutil"
1626
)
@@ -230,4 +240,205 @@ func TestTemplateEdit(t *testing.T) {
230240
assert.Equal(t, "", updated.Icon)
231241
assert.Equal(t, "", updated.DisplayName)
232242
})
243+
t.Run("MaxTTL", func(t *testing.T) {
244+
t.Parallel()
245+
t.Run("BlockedAGPL", func(t *testing.T) {
246+
t.Parallel()
247+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
248+
user := coderdtest.CreateFirstUser(t, client)
249+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
250+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
251+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
252+
ctr.DefaultTTLMillis = nil
253+
ctr.MaxTTLMillis = nil
254+
})
255+
256+
// Test the cli command.
257+
cmdArgs := []string{
258+
"templates",
259+
"edit",
260+
template.Name,
261+
"--max-ttl", "1h",
262+
}
263+
cmd, root := clitest.New(t, cmdArgs...)
264+
clitest.SetupConfig(t, client, root)
265+
266+
ctx, _ := testutil.Context(t)
267+
err := cmd.ExecuteContext(ctx)
268+
require.Error(t, err)
269+
require.ErrorContains(t, err, "appears to be an AGPL deployment")
270+
271+
// Assert that the template metadata did not change.
272+
updated, err := client.Template(context.Background(), template.ID)
273+
require.NoError(t, err)
274+
assert.Equal(t, template.Name, updated.Name)
275+
assert.Equal(t, template.Description, updated.Description)
276+
assert.Equal(t, template.Icon, updated.Icon)
277+
assert.Equal(t, template.DisplayName, updated.DisplayName)
278+
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
279+
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
280+
})
281+
282+
t.Run("BlockedNotEntitled", func(t *testing.T) {
283+
t.Parallel()
284+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
285+
user := coderdtest.CreateFirstUser(t, client)
286+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
287+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
288+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
289+
ctr.DefaultTTLMillis = nil
290+
ctr.MaxTTLMillis = nil
291+
})
292+
293+
// Make a proxy server that will return a valid entitlements
294+
// response, but without advanced scheduling entitlement.
295+
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
296+
if r.URL.Path == "/api/v2/entitlements" {
297+
res := codersdk.Entitlements{
298+
Features: map[codersdk.FeatureName]codersdk.Feature{},
299+
Warnings: []string{},
300+
Errors: []string{},
301+
HasLicense: true,
302+
Trial: true,
303+
RequireTelemetry: false,
304+
Experimental: false,
305+
}
306+
for _, feature := range codersdk.FeatureNames {
307+
res.Features[feature] = codersdk.Feature{
308+
Entitlement: codersdk.EntitlementNotEntitled,
309+
Enabled: false,
310+
Limit: nil,
311+
Actual: nil,
312+
}
313+
}
314+
httpapi.Write(r.Context(), w, http.StatusOK, res)
315+
return
316+
}
317+
318+
// Otherwise, proxy the request to the real API server.
319+
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
320+
}))
321+
defer proxy.Close()
322+
323+
// Create a new client that uses the proxy server.
324+
proxyURL, err := url.Parse(proxy.URL)
325+
require.NoError(t, err)
326+
proxyClient := codersdk.New(proxyURL)
327+
proxyClient.SetSessionToken(client.SessionToken())
328+
329+
// Test the cli command.
330+
cmdArgs := []string{
331+
"templates",
332+
"edit",
333+
template.Name,
334+
"--max-ttl", "1h",
335+
}
336+
cmd, root := clitest.New(t, cmdArgs...)
337+
clitest.SetupConfig(t, proxyClient, root)
338+
339+
ctx, _ := testutil.Context(t)
340+
err = cmd.ExecuteContext(ctx)
341+
require.Error(t, err)
342+
require.ErrorContains(t, err, "license is not entitled")
343+
344+
// Assert that the template metadata did not change.
345+
updated, err := client.Template(context.Background(), template.ID)
346+
require.NoError(t, err)
347+
assert.Equal(t, template.Name, updated.Name)
348+
assert.Equal(t, template.Description, updated.Description)
349+
assert.Equal(t, template.Icon, updated.Icon)
350+
assert.Equal(t, template.DisplayName, updated.DisplayName)
351+
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
352+
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
353+
})
354+
t.Run("Entitled", func(t *testing.T) {
355+
t.Parallel()
356+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
357+
user := coderdtest.CreateFirstUser(t, client)
358+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
359+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
360+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
361+
ctr.DefaultTTLMillis = nil
362+
ctr.MaxTTLMillis = nil
363+
})
364+
365+
// Make a proxy server that will return a valid entitlements
366+
// response, including a valid advanced scheduling entitlement.
367+
var updateTemplateCalled int64
368+
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
369+
if r.URL.Path == "/api/v2/entitlements" {
370+
res := codersdk.Entitlements{
371+
Features: map[codersdk.FeatureName]codersdk.Feature{},
372+
Warnings: []string{},
373+
Errors: []string{},
374+
HasLicense: true,
375+
Trial: true,
376+
RequireTelemetry: false,
377+
Experimental: false,
378+
}
379+
for _, feature := range codersdk.FeatureNames {
380+
var one int64 = 1
381+
res.Features[feature] = codersdk.Feature{
382+
Entitlement: codersdk.EntitlementNotEntitled,
383+
Enabled: true,
384+
Limit: &one,
385+
Actual: &one,
386+
}
387+
}
388+
httpapi.Write(r.Context(), w, http.StatusOK, res)
389+
return
390+
}
391+
if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") {
392+
body, err := io.ReadAll(r.Body)
393+
require.NoError(t, err)
394+
_ = r.Body.Close()
395+
396+
var req codersdk.UpdateTemplateMeta
397+
err = json.Unmarshal(body, &req)
398+
require.NoError(t, err)
399+
assert.Equal(t, time.Hour.Milliseconds(), req.MaxTTLMillis)
400+
401+
r.Body = io.NopCloser(bytes.NewReader(body))
402+
atomic.AddInt64(&updateTemplateCalled, 1)
403+
// We still want to call the real route.
404+
}
405+
406+
// Otherwise, proxy the request to the real API server.
407+
httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r)
408+
}))
409+
defer proxy.Close()
410+
411+
// Create a new client that uses the proxy server.
412+
proxyURL, err := url.Parse(proxy.URL)
413+
require.NoError(t, err)
414+
proxyClient := codersdk.New(proxyURL)
415+
proxyClient.SetSessionToken(client.SessionToken())
416+
417+
// Test the cli command.
418+
cmdArgs := []string{
419+
"templates",
420+
"edit",
421+
template.Name,
422+
"--max-ttl", "1h",
423+
}
424+
cmd, root := clitest.New(t, cmdArgs...)
425+
clitest.SetupConfig(t, proxyClient, root)
426+
427+
ctx, _ := testutil.Context(t)
428+
err = cmd.ExecuteContext(ctx)
429+
require.NoError(t, err)
430+
431+
require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled))
432+
433+
// Assert that the template metadata did not change.
434+
updated, err := client.Template(context.Background(), template.ID)
435+
require.NoError(t, err)
436+
assert.Equal(t, template.Name, updated.Name)
437+
assert.Equal(t, template.Description, updated.Description)
438+
assert.Equal(t, template.Icon, updated.Icon)
439+
assert.Equal(t, template.DisplayName, updated.DisplayName)
440+
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
441+
assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis)
442+
})
443+
})
233444
}

cli/testdata/coder_templates_edit_--help.golden

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ Flags:
77
--allow-user-cancel-workspace-jobs Allow users to cancel in-progress workspace jobs.
88
(default true)
99
--default-ttl duration Edit the template default time before shutdown -
10-
workspaces created from this template to this value.
11-
--description string Edit the template description
12-
--display-name string Edit the template display name
10+
workspaces created from this template default to
11+
this value.
12+
--description string Edit the template description.
13+
--display-name string Edit the template display name.
1314
-h, --help help for edit
14-
--icon string Edit the template icon path
15-
--name string Edit the template name
15+
--icon string Edit the template icon path.
16+
--max-ttl duration Edit the template maximum time before shutdown -
17+
workspaces created from this template must shutdown
18+
within the given duration after starting. This is
19+
an enterprise-only feature.
20+
--name string Edit the template name.
1621
-y, --yes Bypass prompts
1722

1823
Global Flags:

site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ const defaultInitialValues: CreateTemplateData = {
5050
parameter_values_by_name: undefined,
5151
}
5252

53-
const getInitialValues = (canSetMaxTTL: boolean, starterTemplate?: TemplateExample) => {
53+
const getInitialValues = (
54+
canSetMaxTTL: boolean,
55+
starterTemplate?: TemplateExample,
56+
) => {
5457
let initialValues = defaultInitialValues
5558
if (!canSetMaxTTL) {
5659
initialValues = {
@@ -97,7 +100,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
97100
const styles = useStyles()
98101
const formFooterStyles = useFormFooterStyles()
99102
const { entitlements } = useDashboard()
100-
const canSetMaxTTL = entitlements.features["advanced_template_scheduling"].enabled
103+
const canSetMaxTTL =
104+
entitlements.features["advanced_template_scheduling"].enabled
101105

102106
const form = useFormik<CreateTemplateData>({
103107
initialValues: getInitialValues(canSetMaxTTL, starterTemplate),

0 commit comments

Comments
 (0)