Skip to content

Commit 931b97c

Browse files
authored
feat(cli): add CLI support for listing presets (#18910)
## Description This PR introduces a new `list presets` command to display the presets associated with a given template. By default, it displays the presets for the template's active version, unless a `--template-version` flag is provided. ## Changes * Added a new `list presets` command under `coder templates presets` to display presets associated with a template. * By default, the command lists presets from the template’s active version. * Users can override the default behavior by providing the `--template-version` flag to target a specific version. ``` > coder templates versions presets list --help USAGE: coder templates presets list [flags] <template> List all presets of the specified template. Defaults to the active template version. OPTIONS: -O, --org string, $CODER_ORGANIZATION Select which organization (uuid or name) to use. -c, --column [name|parameters|default|desired prebuild instances] (default: name,parameters,default,desired prebuild instances) Columns to display in table output. -o, --output table|json (default: table) Output format. --template-version string Specify a template version to list presets for. Defaults to the active version. ``` Related PR: #18912 - please consider both PRs together as they’re part of the same workflow Relates to issue: #16594 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added CLI commands to manage and list presets for specific template versions, supporting tabular and JSON output. * Introduced a new CLI subcommand group for template version presets, including detailed help and documentation. * Added support for displaying and managing the desired number of prebuild instances for presets in CLI, API, and UI. * **Documentation** * Updated and expanded CLI and API documentation to describe new commands, options, and the desired prebuild instances field in presets. * Added new help output and reference files for template version presets commands. * **Bug Fixes** * Ensured correct handling and display of the desired prebuild instances property for presets across CLI, API, and UI. * **Tests** * Introduced end-to-end tests for listing template version presets, covering scenarios with and without presets. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 070178c commit 931b97c

18 files changed

+584
-22
lines changed

cli/templatepresets.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
8+
"golang.org/x/xerrors"
9+
10+
"github.com/coder/coder/v2/cli/cliui"
11+
"github.com/coder/coder/v2/codersdk"
12+
"github.com/coder/serpent"
13+
)
14+
15+
func (r *RootCmd) templatePresets() *serpent.Command {
16+
cmd := &serpent.Command{
17+
Use: "presets",
18+
Short: "Manage presets of the specified template",
19+
Aliases: []string{"preset"},
20+
Long: FormatExamples(
21+
Example{
22+
Description: "List presets for the active version of a template",
23+
Command: "coder templates presets list my-template",
24+
},
25+
Example{
26+
Description: "List presets for a specific version of a template",
27+
Command: "coder templates presets list my-template --template-version my-template-version",
28+
},
29+
),
30+
Handler: func(inv *serpent.Invocation) error {
31+
return inv.Command.HelpHandler(inv)
32+
},
33+
Children: []*serpent.Command{
34+
r.templatePresetsList(),
35+
},
36+
}
37+
38+
return cmd
39+
}
40+
41+
func (r *RootCmd) templatePresetsList() *serpent.Command {
42+
defaultColumns := []string{
43+
"name",
44+
"parameters",
45+
"default",
46+
"desired prebuild instances",
47+
}
48+
formatter := cliui.NewOutputFormatter(
49+
cliui.TableFormat([]templatePresetRow{}, defaultColumns),
50+
cliui.JSONFormat(),
51+
)
52+
client := new(codersdk.Client)
53+
orgContext := NewOrganizationContext()
54+
55+
var templateVersion string
56+
57+
cmd := &serpent.Command{
58+
Use: "list <template>",
59+
Middleware: serpent.Chain(
60+
serpent.RequireNArgs(1),
61+
r.InitClient(client),
62+
),
63+
Short: "List all presets of the specified template. Defaults to the active template version.",
64+
Options: serpent.OptionSet{
65+
{
66+
Name: "template-version",
67+
Description: "Specify a template version to list presets for. Defaults to the active version.",
68+
Flag: "template-version",
69+
Value: serpent.StringOf(&templateVersion),
70+
},
71+
},
72+
Handler: func(inv *serpent.Invocation) error {
73+
organization, err := orgContext.Selected(inv, client)
74+
if err != nil {
75+
return xerrors.Errorf("get current organization: %w", err)
76+
}
77+
78+
template, err := client.TemplateByName(inv.Context(), organization.ID, inv.Args[0])
79+
if err != nil {
80+
return xerrors.Errorf("get template by name: %w", err)
81+
}
82+
83+
// If a template version is specified via flag, fetch that version by name
84+
var version codersdk.TemplateVersion
85+
if len(templateVersion) > 0 {
86+
version, err = client.TemplateVersionByName(inv.Context(), template.ID, templateVersion)
87+
if err != nil {
88+
return xerrors.Errorf("get template version by name: %w", err)
89+
}
90+
} else {
91+
// Otherwise, use the template's active version
92+
version, err = client.TemplateVersion(inv.Context(), template.ActiveVersionID)
93+
if err != nil {
94+
return xerrors.Errorf("get active template version: %w", err)
95+
}
96+
}
97+
98+
presets, err := client.TemplateVersionPresets(inv.Context(), version.ID)
99+
if err != nil {
100+
return xerrors.Errorf("get template versions presets by template version: %w", err)
101+
}
102+
103+
if len(presets) == 0 {
104+
cliui.Infof(
105+
inv.Stdout,
106+
"No presets found for template %q and template-version %q.\n", template.Name, version.Name,
107+
)
108+
return nil
109+
}
110+
111+
cliui.Infof(
112+
inv.Stdout,
113+
"Showing presets for template %q and template version %q.\n", template.Name, version.Name,
114+
)
115+
rows := templatePresetsToRows(presets...)
116+
out, err := formatter.Format(inv.Context(), rows)
117+
if err != nil {
118+
return xerrors.Errorf("render table: %w", err)
119+
}
120+
121+
_, err = fmt.Fprintln(inv.Stdout, out)
122+
return err
123+
},
124+
}
125+
126+
orgContext.AttachOptions(cmd)
127+
formatter.AttachOptions(&cmd.Options)
128+
return cmd
129+
}
130+
131+
type templatePresetRow struct {
132+
// For json format:
133+
TemplatePreset codersdk.Preset `table:"-"`
134+
135+
// For table format:
136+
Name string `json:"-" table:"name,default_sort"`
137+
Parameters string `json:"-" table:"parameters"`
138+
Default bool `json:"-" table:"default"`
139+
DesiredPrebuildInstances string `json:"-" table:"desired prebuild instances"`
140+
}
141+
142+
func formatPresetParameters(params []codersdk.PresetParameter) string {
143+
var paramsStr []string
144+
for _, p := range params {
145+
paramsStr = append(paramsStr, fmt.Sprintf("%s=%s", p.Name, p.Value))
146+
}
147+
return strings.Join(paramsStr, ",")
148+
}
149+
150+
// templatePresetsToRows converts a list of presets to a list of rows
151+
// for outputting.
152+
func templatePresetsToRows(presets ...codersdk.Preset) []templatePresetRow {
153+
rows := make([]templatePresetRow, len(presets))
154+
for i, preset := range presets {
155+
prebuildInstances := "-"
156+
if preset.DesiredPrebuildInstances != nil {
157+
prebuildInstances = strconv.Itoa(*preset.DesiredPrebuildInstances)
158+
}
159+
rows[i] = templatePresetRow{
160+
Name: preset.Name,
161+
Parameters: formatPresetParameters(preset.Parameters),
162+
Default: preset.Default,
163+
DesiredPrebuildInstances: prebuildInstances,
164+
}
165+
}
166+
167+
return rows
168+
}

cli/templatepresets_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package cli_test
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/cli/clitest"
10+
"github.com/coder/coder/v2/coderd/coderdtest"
11+
"github.com/coder/coder/v2/codersdk"
12+
"github.com/coder/coder/v2/provisioner/echo"
13+
"github.com/coder/coder/v2/provisionersdk/proto"
14+
"github.com/coder/coder/v2/pty/ptytest"
15+
"github.com/coder/coder/v2/testutil"
16+
)
17+
18+
func TestTemplatePresets(t *testing.T) {
19+
t.Parallel()
20+
21+
t.Run("NoPresets", func(t *testing.T) {
22+
t.Parallel()
23+
24+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
25+
owner := coderdtest.CreateFirstUser(t, client)
26+
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
27+
28+
// Given: a template version without presets
29+
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets([]*proto.Preset{}))
30+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
31+
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
32+
33+
// When: listing presets for that template
34+
inv, root := clitest.New(t, "templates", "presets", "list", template.Name)
35+
clitest.SetupConfig(t, member, root)
36+
37+
pty := ptytest.New(t).Attach(inv)
38+
doneChan := make(chan struct{})
39+
var runErr error
40+
go func() {
41+
defer close(doneChan)
42+
runErr = inv.Run()
43+
}()
44+
<-doneChan
45+
require.NoError(t, runErr)
46+
47+
// Should return a message when no presets are found for the given template and version.
48+
notFoundMessage := fmt.Sprintf("No presets found for template %q and template-version %q.", template.Name, version.Name)
49+
pty.ExpectRegexMatch(notFoundMessage)
50+
})
51+
52+
t.Run("ListsPresetsForDefaultTemplateVersion", func(t *testing.T) {
53+
t.Parallel()
54+
55+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
56+
owner := coderdtest.CreateFirstUser(t, client)
57+
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
58+
59+
// Given: an active template version that includes presets
60+
presets := []*proto.Preset{
61+
{
62+
Name: "preset-multiple-params",
63+
Parameters: []*proto.PresetParameter{
64+
{
65+
Name: "k1",
66+
Value: "v1",
67+
}, {
68+
Name: "k2",
69+
Value: "v2",
70+
},
71+
},
72+
},
73+
{
74+
Name: "preset-default",
75+
Default: true,
76+
Parameters: []*proto.PresetParameter{
77+
{
78+
Name: "k1",
79+
Value: "v2",
80+
},
81+
},
82+
Prebuild: &proto.Prebuild{
83+
Instances: 0,
84+
},
85+
},
86+
{
87+
Name: "preset-prebuilds",
88+
Parameters: []*proto.PresetParameter{},
89+
Prebuild: &proto.Prebuild{
90+
Instances: 2,
91+
},
92+
},
93+
}
94+
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets(presets))
95+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
96+
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
97+
require.Equal(t, version.ID, template.ActiveVersionID)
98+
99+
// When: listing presets for that template
100+
inv, root := clitest.New(t, "templates", "presets", "list", template.Name)
101+
clitest.SetupConfig(t, member, root)
102+
103+
pty := ptytest.New(t).Attach(inv)
104+
doneChan := make(chan struct{})
105+
var runErr error
106+
go func() {
107+
defer close(doneChan)
108+
runErr = inv.Run()
109+
}()
110+
111+
<-doneChan
112+
require.NoError(t, runErr)
113+
114+
// Should: return the active version's presets sorted by name
115+
message := fmt.Sprintf("Showing presets for template %q and template version %q.", template.Name, version.Name)
116+
pty.ExpectMatch(message)
117+
pty.ExpectRegexMatch(`preset-default\s+k1=v2\s+true\s+0`)
118+
// The parameter order is not guaranteed in the output, so we match both possible orders
119+
pty.ExpectRegexMatch(`preset-multiple-params\s+(k1=v1,k2=v2)|(k2=v2,k1=v1)\s+false\s+-`)
120+
pty.ExpectRegexMatch(`preset-prebuilds\s+\s+false\s+2`)
121+
})
122+
123+
t.Run("ListsPresetsForSpecifiedTemplateVersion", func(t *testing.T) {
124+
t.Parallel()
125+
126+
ctx := testutil.Context(t, testutil.WaitMedium)
127+
128+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
129+
owner := coderdtest.CreateFirstUser(t, client)
130+
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
131+
132+
// Given: a template with an active version that has no presets,
133+
// and another template version that includes presets
134+
presets := []*proto.Preset{
135+
{
136+
Name: "preset-multiple-params",
137+
Parameters: []*proto.PresetParameter{
138+
{
139+
Name: "k1",
140+
Value: "v1",
141+
}, {
142+
Name: "k2",
143+
Value: "v2",
144+
},
145+
},
146+
},
147+
{
148+
Name: "preset-default",
149+
Default: true,
150+
Parameters: []*proto.PresetParameter{
151+
{
152+
Name: "k1",
153+
Value: "v2",
154+
},
155+
},
156+
Prebuild: &proto.Prebuild{
157+
Instances: 0,
158+
},
159+
},
160+
{
161+
Name: "preset-prebuilds",
162+
Parameters: []*proto.PresetParameter{},
163+
Prebuild: &proto.Prebuild{
164+
Instances: 2,
165+
},
166+
},
167+
}
168+
// Given: first template version with presets
169+
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets(presets))
170+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
171+
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
172+
// Given: second template version without presets
173+
activeVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets([]*proto.Preset{}), template.ID)
174+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, activeVersion.ID)
175+
// Given: second template version is the active version
176+
err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
177+
ID: activeVersion.ID,
178+
})
179+
require.NoError(t, err)
180+
updatedTemplate, err := client.Template(ctx, template.ID)
181+
require.NoError(t, err)
182+
require.Equal(t, activeVersion.ID, updatedTemplate.ActiveVersionID)
183+
// Given: template has two versions
184+
templateVersions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{
185+
TemplateID: updatedTemplate.ID,
186+
})
187+
require.NoError(t, err)
188+
require.Len(t, templateVersions, 2)
189+
190+
// When: listing presets for a specific template and its specified version
191+
inv, root := clitest.New(t, "templates", "presets", "list", updatedTemplate.Name, "--template-version", version.Name)
192+
clitest.SetupConfig(t, member, root)
193+
194+
pty := ptytest.New(t).Attach(inv)
195+
doneChan := make(chan struct{})
196+
var runErr error
197+
go func() {
198+
defer close(doneChan)
199+
runErr = inv.Run()
200+
}()
201+
202+
<-doneChan
203+
require.NoError(t, runErr)
204+
205+
// Should: return the specified version's presets sorted by name
206+
message := fmt.Sprintf("Showing presets for template %q and template version %q.", template.Name, version.Name)
207+
pty.ExpectMatch(message)
208+
pty.ExpectRegexMatch(`preset-default\s+k1=v2\s+true\s+0`)
209+
// The parameter order is not guaranteed in the output, so we match both possible orders
210+
pty.ExpectRegexMatch(`preset-multiple-params\s+(k1=v1,k2=v2)|(k2=v2,k1=v1)\s+false\s+-`)
211+
pty.ExpectRegexMatch(`preset-prebuilds\s+\s+false\s+2`)
212+
})
213+
}
214+
215+
func templateWithPresets(presets []*proto.Preset) *echo.Responses {
216+
return &echo.Responses{
217+
Parse: echo.ParseComplete,
218+
ProvisionPlan: []*proto.Response{
219+
{
220+
Type: &proto.Response_Plan{
221+
Plan: &proto.PlanComplete{
222+
Presets: presets,
223+
},
224+
},
225+
},
226+
},
227+
}
228+
}

0 commit comments

Comments
 (0)