Skip to content

[pull] main from coder:main #112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
168 changes: 168 additions & 0 deletions cli/templatepresets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package cli

import (
"fmt"
"strconv"
"strings"

"golang.org/x/xerrors"

"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)

func (r *RootCmd) templatePresets() *serpent.Command {
cmd := &serpent.Command{
Use: "presets",
Short: "Manage presets of the specified template",
Aliases: []string{"preset"},
Long: FormatExamples(
Example{
Description: "List presets for the active version of a template",
Command: "coder templates presets list my-template",
},
Example{
Description: "List presets for a specific version of a template",
Command: "coder templates presets list my-template --template-version my-template-version",
},
),
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.templatePresetsList(),
},
}

return cmd
}

func (r *RootCmd) templatePresetsList() *serpent.Command {
defaultColumns := []string{
"name",
"parameters",
"default",
"desired prebuild instances",
}
formatter := cliui.NewOutputFormatter(
cliui.TableFormat([]templatePresetRow{}, defaultColumns),
cliui.JSONFormat(),
)
client := new(codersdk.Client)
orgContext := NewOrganizationContext()

var templateVersion string

cmd := &serpent.Command{
Use: "list <template>",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
r.InitClient(client),
),
Short: "List all presets of the specified template. Defaults to the active template version.",
Options: serpent.OptionSet{
{
Name: "template-version",
Description: "Specify a template version to list presets for. Defaults to the active version.",
Flag: "template-version",
Value: serpent.StringOf(&templateVersion),
},
},
Handler: func(inv *serpent.Invocation) error {
organization, err := orgContext.Selected(inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}

template, err := client.TemplateByName(inv.Context(), organization.ID, inv.Args[0])
if err != nil {
return xerrors.Errorf("get template by name: %w", err)
}

// If a template version is specified via flag, fetch that version by name
var version codersdk.TemplateVersion
if len(templateVersion) > 0 {
version, err = client.TemplateVersionByName(inv.Context(), template.ID, templateVersion)
if err != nil {
return xerrors.Errorf("get template version by name: %w", err)
}
} else {
// Otherwise, use the template's active version
version, err = client.TemplateVersion(inv.Context(), template.ActiveVersionID)
if err != nil {
return xerrors.Errorf("get active template version: %w", err)
}
}

presets, err := client.TemplateVersionPresets(inv.Context(), version.ID)
if err != nil {
return xerrors.Errorf("get template versions presets by template version: %w", err)
}

if len(presets) == 0 {
cliui.Infof(
inv.Stdout,
"No presets found for template %q and template-version %q.\n", template.Name, version.Name,
)
return nil
}

cliui.Infof(
inv.Stdout,
"Showing presets for template %q and template version %q.\n", template.Name, version.Name,
)
rows := templatePresetsToRows(presets...)
out, err := formatter.Format(inv.Context(), rows)
if err != nil {
return xerrors.Errorf("render table: %w", err)
}

_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}

orgContext.AttachOptions(cmd)
formatter.AttachOptions(&cmd.Options)
return cmd
}

type templatePresetRow struct {
// For json format:
TemplatePreset codersdk.Preset `table:"-"`

// For table format:
Name string `json:"-" table:"name,default_sort"`
Parameters string `json:"-" table:"parameters"`
Default bool `json:"-" table:"default"`
DesiredPrebuildInstances string `json:"-" table:"desired prebuild instances"`
}

func formatPresetParameters(params []codersdk.PresetParameter) string {
var paramsStr []string
for _, p := range params {
paramsStr = append(paramsStr, fmt.Sprintf("%s=%s", p.Name, p.Value))
}
return strings.Join(paramsStr, ",")
}

// templatePresetsToRows converts a list of presets to a list of rows
// for outputting.
func templatePresetsToRows(presets ...codersdk.Preset) []templatePresetRow {
rows := make([]templatePresetRow, len(presets))
for i, preset := range presets {
prebuildInstances := "-"
if preset.DesiredPrebuildInstances != nil {
prebuildInstances = strconv.Itoa(*preset.DesiredPrebuildInstances)
}
rows[i] = templatePresetRow{
Name: preset.Name,
Parameters: formatPresetParameters(preset.Parameters),
Default: preset.Default,
DesiredPrebuildInstances: prebuildInstances,
}
}

return rows
}
228 changes: 228 additions & 0 deletions cli/templatepresets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package cli_test

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)

func TestTemplatePresets(t *testing.T) {
t.Parallel()

t.Run("NoPresets", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)

// Given: a template version without presets
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets([]*proto.Preset{}))
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)

// When: listing presets for that template
inv, root := clitest.New(t, "templates", "presets", "list", template.Name)
clitest.SetupConfig(t, member, root)

pty := ptytest.New(t).Attach(inv)
doneChan := make(chan struct{})
var runErr error
go func() {
defer close(doneChan)
runErr = inv.Run()
}()
<-doneChan
require.NoError(t, runErr)

// Should return a message when no presets are found for the given template and version.
notFoundMessage := fmt.Sprintf("No presets found for template %q and template-version %q.", template.Name, version.Name)
pty.ExpectRegexMatch(notFoundMessage)
})

t.Run("ListsPresetsForDefaultTemplateVersion", func(t *testing.T) {
t.Parallel()

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)

// Given: an active template version that includes presets
presets := []*proto.Preset{
{
Name: "preset-multiple-params",
Parameters: []*proto.PresetParameter{
{
Name: "k1",
Value: "v1",
}, {
Name: "k2",
Value: "v2",
},
},
},
{
Name: "preset-default",
Default: true,
Parameters: []*proto.PresetParameter{
{
Name: "k1",
Value: "v2",
},
},
Prebuild: &proto.Prebuild{
Instances: 0,
},
},
{
Name: "preset-prebuilds",
Parameters: []*proto.PresetParameter{},
Prebuild: &proto.Prebuild{
Instances: 2,
},
},
}
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets(presets))
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
require.Equal(t, version.ID, template.ActiveVersionID)

// When: listing presets for that template
inv, root := clitest.New(t, "templates", "presets", "list", template.Name)
clitest.SetupConfig(t, member, root)

pty := ptytest.New(t).Attach(inv)
doneChan := make(chan struct{})
var runErr error
go func() {
defer close(doneChan)
runErr = inv.Run()
}()

<-doneChan
require.NoError(t, runErr)

// Should: return the active version's presets sorted by name
message := fmt.Sprintf("Showing presets for template %q and template version %q.", template.Name, version.Name)
pty.ExpectMatch(message)
pty.ExpectRegexMatch(`preset-default\s+k1=v2\s+true\s+0`)
// The parameter order is not guaranteed in the output, so we match both possible orders
pty.ExpectRegexMatch(`preset-multiple-params\s+(k1=v1,k2=v2)|(k2=v2,k1=v1)\s+false\s+-`)
pty.ExpectRegexMatch(`preset-prebuilds\s+\s+false\s+2`)
})

t.Run("ListsPresetsForSpecifiedTemplateVersion", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitMedium)

client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)

// Given: a template with an active version that has no presets,
// and another template version that includes presets
presets := []*proto.Preset{
{
Name: "preset-multiple-params",
Parameters: []*proto.PresetParameter{
{
Name: "k1",
Value: "v1",
}, {
Name: "k2",
Value: "v2",
},
},
},
{
Name: "preset-default",
Default: true,
Parameters: []*proto.PresetParameter{
{
Name: "k1",
Value: "v2",
},
},
Prebuild: &proto.Prebuild{
Instances: 0,
},
},
{
Name: "preset-prebuilds",
Parameters: []*proto.PresetParameter{},
Prebuild: &proto.Prebuild{
Instances: 2,
},
},
}
// Given: first template version with presets
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets(presets))
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Given: second template version without presets
activeVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, templateWithPresets([]*proto.Preset{}), template.ID)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, activeVersion.ID)
// Given: second template version is the active version
err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
ID: activeVersion.ID,
})
require.NoError(t, err)
updatedTemplate, err := client.Template(ctx, template.ID)
require.NoError(t, err)
require.Equal(t, activeVersion.ID, updatedTemplate.ActiveVersionID)
// Given: template has two versions
templateVersions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{
TemplateID: updatedTemplate.ID,
})
require.NoError(t, err)
require.Len(t, templateVersions, 2)

// When: listing presets for a specific template and its specified version
inv, root := clitest.New(t, "templates", "presets", "list", updatedTemplate.Name, "--template-version", version.Name)
clitest.SetupConfig(t, member, root)

pty := ptytest.New(t).Attach(inv)
doneChan := make(chan struct{})
var runErr error
go func() {
defer close(doneChan)
runErr = inv.Run()
}()

<-doneChan
require.NoError(t, runErr)

// Should: return the specified version's presets sorted by name
message := fmt.Sprintf("Showing presets for template %q and template version %q.", template.Name, version.Name)
pty.ExpectMatch(message)
pty.ExpectRegexMatch(`preset-default\s+k1=v2\s+true\s+0`)
// The parameter order is not guaranteed in the output, so we match both possible orders
pty.ExpectRegexMatch(`preset-multiple-params\s+(k1=v1,k2=v2)|(k2=v2,k1=v1)\s+false\s+-`)
pty.ExpectRegexMatch(`preset-prebuilds\s+\s+false\s+2`)
})
}

func templateWithPresets(presets []*proto.Preset) *echo.Responses {
return &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Presets: presets,
},
},
},
},
}
}
Loading
Loading