Files
coder/cli/workspace_template_version_test.go
T
dylanhuff-at-coderandNick Vigilante b4d51012da feat: allow template version selection for workspace builds (#30199)
Add named template version selection to `coder start` and `coder stop`,
and independent stop/start selection to `coder update` with
`--stop-template-version` and `--template-version`. Stop still defaults
to the workspace's current version, and update starts with the active
version by default. Explicit selections use the selected version for
parameter preparation and builds, keep server permissions authoritative,
and never silently fall back to the active version.

Classic stop builds retain previous parameter values omitted by the
selected stop version, so a later start can reuse them across separate
commands or an interrupted update. Declared parameters still follow
normal validation, and start builds still discard removed parameters.
Update now returns an error and retry guidance when a start apply fails
after stopping, including unflagged updates that previously returned
success.

Closes #29100


[PLAT-616](https://linear.app/codercom/issue/PLAT-616/cli-allow-specifying-a-template-version-for-stopstart-builds-and-both)

---------

Co-authored-by: Nick Vigilante <nickvigilante@users.noreply.github.com>
2026-10-07 13:17:12 -07:00

390 lines
16 KiB
Go

package cli_test
import (
"bytes"
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"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/testutil"
)
func TestWorkspaceBuildTemplateVersion(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
command string
startPin bool
stopPin bool
stopped bool
sameVersion bool
missing bool
wantErr string
}{
{name: "Stop", command: "stop", startPin: true},
{name: "StartRunning", command: "start", startPin: true},
{name: "StartStopped", command: "start", startPin: true, stopped: true},
{name: "StartSameVersion", command: "start", startPin: true, sameVersion: true},
{name: "UpdateStart", command: "update", startPin: true},
{name: "UpdateStop", command: "update", stopPin: true},
{name: "UpdateBoth", command: "update", startPin: true, stopPin: true},
{name: "UpdateStopped", command: "update", startPin: true, stopPin: true, stopped: true},
{name: "UpdateStopOnlyStopped", command: "update", stopPin: true, stopped: true, wantErr: "already stopped and up-to-date"},
{name: "UpdateMissingStart", command: "update", startPin: true, stopPin: true, missing: true},
{name: "UpdateMissingStop", command: "update", stopPin: true, missing: true},
} {
t.Run(tc.name, 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)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
if tc.stopped {
coderdtest.MustTransitionWorkspace(t, member, workspace.ID,
codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
}
target := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID,
prepareEchoResponses([]*proto.RichParameter{
{Name: "target_parameter", Type: "string", Mutable: true, DefaultValue: "target"},
}), func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, target.ID)
stopTarget := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil,
func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, stopTarget.ID)
// Keep the workspace current so explicit update flags must bypass
// the up-to-date shortcut. Targets remain non-active.
ctx := testutil.Context(t, testutil.WaitLong)
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.False(t, workspace.Outdated)
before := workspace.LatestBuild.BuildNumber
args := []string{tc.command, workspace.Name, "-y"}
startID := version.ID
if tc.startPin {
name := target.Name
startID = target.ID
if tc.sameVersion {
name = version.Name
startID = version.ID
}
if tc.missing {
name = uuid.NewString()
}
args = append(args, "--template-version", name)
}
if tc.stopPin {
name := stopTarget.Name
if tc.missing && !tc.startPin {
name = uuid.NewString()
}
args = append(args, "--stop-template-version", name)
}
if tc.command != "stop" {
args = append(args, "--use-parameter-defaults")
if tc.startPin && !tc.sameVersion && !tc.missing {
args = append(args, "--parameter", "target_parameter=chosen")
}
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
var stderr bytes.Buffer
inv.Stderr = &stderr
err = inv.Run()
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
after, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, before, after.LatestBuild.BuildNumber)
require.Equal(t, codersdk.WorkspaceTransitionStop, after.LatestBuild.Transition)
return
}
if tc.missing {
require.Error(t, err)
require.Contains(t, err.Error(), "get template version by name")
after, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, before, after.LatestBuild.BuildNumber)
return
}
require.NoError(t, err)
after, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
if tc.command == "update" && tc.stopPin && tc.stopped {
require.Contains(t, stderr.String(), "ignoring --stop-template-version")
}
if tc.command == "stop" {
require.Equal(t, before+1, after.LatestBuild.BuildNumber)
require.Equal(t, codersdk.WorkspaceTransitionStop, after.LatestBuild.Transition)
require.Equal(t, target.ID, after.LatestBuild.TemplateVersionID)
return
}
expectedBuilds := before + 1
if tc.command == "update" && !tc.stopped {
expectedBuilds++
stopBuild, err := client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
ctx, workspace.OwnerName, workspace.Name, fmt.Sprint(before+1))
require.NoError(t, err)
require.Equal(t, codersdk.WorkspaceTransitionStop, stopBuild.Transition)
stopID := version.ID
if tc.stopPin {
stopID = stopTarget.ID
}
require.Equal(t, stopID, stopBuild.TemplateVersionID)
}
require.Equal(t, expectedBuilds, after.LatestBuild.BuildNumber)
require.Equal(t, codersdk.WorkspaceTransitionStart, after.LatestBuild.Transition)
require.Equal(t, startID, after.LatestBuild.TemplateVersionID)
if startID == target.ID {
parameters, err := client.WorkspaceBuildParameters(ctx, after.LatestBuild.ID)
require.NoError(t, err)
require.Contains(t, parameters, codersdk.WorkspaceBuildParameter{
Name: "target_parameter", Value: "chosen",
})
}
})
}
}
func TestTemplateVersionClassicParameterContinuity(t *testing.T) {
t.Parallel()
for _, required := range []bool{false, true} {
for _, mode := range []string{"Update", "SeparateCommands", "InterruptedUpdate"} {
t.Run(fmt.Sprintf("%s/Required=%t", mode, required), 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)
prior := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID,
prepareEchoResponses([]*proto.RichParameter{{
Name: "disk_size", Type: "string", Required: required, DefaultValue: "64",
}}))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, prior.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, prior.ID,
func(req *codersdk.CreateTemplateRequest) {
classic := true
req.UseClassicParameterFlow = &classic
})
workspace := coderdtest.CreateWorkspace(t, member, template.ID,
func(req *codersdk.CreateWorkspaceRequest) {
req.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{Name: "disk_size", Value: "128"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
stopTarget := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil,
func(req *codersdk.CreateTemplateVersionRequest) { req.TemplateID = template.ID })
coderdtest.AwaitTemplateVersionJobCompleted(t, client, stopTarget.ID)
run := func(args ...string) error {
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
return inv.Run()
}
if mode == "SeparateCommands" {
require.NoError(t, run("stop", workspace.Name, "--template-version", stopTarget.Name, "-y"))
} else {
args := []string{
"update", workspace.Name, "--stop-template-version", stopTarget.Name,
"--use-parameter-defaults", "-y",
}
if mode == "InterruptedUpdate" {
args = append(args, "--parameter", "unknown=value")
err := run(args...)
require.Error(t, err)
require.Contains(t, err.Error(), "workspace is stopped")
} else {
require.NoError(t, run(args...))
}
}
ctx := testutil.Context(t, testutil.WaitLong)
stop, err := client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
ctx, workspace.OwnerName, workspace.Name, "2")
require.NoError(t, err)
require.Equal(t, stopTarget.ID, stop.TemplateVersionID)
require.Equal(t, codersdk.ProvisionerJobSucceeded, stop.Job.Status)
params, err := client.WorkspaceBuildParameters(ctx, stop.ID)
require.NoError(t, err)
require.Contains(t, params, codersdk.WorkspaceBuildParameter{Name: "disk_size", Value: "128"})
if mode != "Update" {
require.NoError(t, run("start", workspace.Name, "--template-version", prior.Name,
"--use-parameter-defaults", "-y"))
}
after := coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, int32(3), after.LatestBuild.BuildNumber)
require.Equal(t, prior.ID, after.LatestBuild.TemplateVersionID)
require.Equal(t, codersdk.WorkspaceStatusRunning, after.LatestBuild.Status)
params, err = client.WorkspaceBuildParameters(ctx, after.LatestBuild.ID)
require.NoError(t, err)
require.Contains(t, params, codersdk.WorkspaceBuildParameter{Name: "disk_size", Value: "128"})
})
}
}
}
func TestUpdateTemplateVersionApplyFailure(t *testing.T) {
t.Parallel()
for _, pinned := range []bool{false, true} {
t.Run(fmt.Sprint(pinned), 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)
prior := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, prior.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, prior.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
const failure = "real start apply failed"
const partialState = "partial start state"
target := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete, ProvisionInit: echo.InitComplete,
ProvisionPlan: echo.PlanComplete, ProvisionGraph: echo.GraphComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{
proto.WorkspaceTransition_START: {
{Type: &proto.Response_Apply{Apply: &proto.ApplyComplete{
Error: failure, State: []byte(partialState),
}}},
},
},
}, func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, target.ID)
args := []string{"update", workspace.Name, "--use-parameter-defaults", "-y"}
if pinned {
args = append(args, "--template-version", target.Name)
} else {
coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, target.ID)
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.Error(t, err)
require.Contains(t, err.Error(), failure)
require.Contains(t, err.Error(), "workspace did not restart successfully")
require.Contains(t, err.Error(), "run 'coder start' to retry")
ctx := testutil.Context(t, testutil.WaitLong)
after := coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, int32(3), after.LatestBuild.BuildNumber)
require.Equal(t, target.ID, after.LatestBuild.TemplateVersionID)
require.Equal(t, codersdk.WorkspaceStatusFailed, after.LatestBuild.Status)
require.Equal(t, failure, after.LatestBuild.Job.Error)
state, err := client.WorkspaceBuildState(ctx, after.LatestBuild.ID)
require.NoError(t, err)
require.Equal(t, partialState, string(state))
stop, err := client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(
ctx, workspace.OwnerName, workspace.Name, "2")
require.NoError(t, err)
require.Equal(t, prior.ID, stop.TemplateVersionID)
require.Equal(t, codersdk.WorkspaceTransitionStop, stop.Transition)
require.Equal(t, codersdk.ProvisionerJobSucceeded, stop.Job.Status)
})
}
}
func TestStartTemplateVersionBuildInProgress(t *testing.T) {
t.Parallel()
for _, starting := range []bool{false, true} {
t.Run(fmt.Sprint(starting), func(t *testing.T) {
t.Parallel()
store, ps := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: store, Pubsub: ps})
owner := coderdtest.CreateFirstUser(t, client)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
builder := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OwnerID: memberUser.ID, OrganizationID: owner.OrganizationID,
})
if starting {
builder = builder.Starting()
} else {
builder = builder.Pending()
}
ws := builder.Do()
ctx := testutil.Context(t, testutil.WaitShort)
version, err := client.TemplateVersion(ctx, ws.Build.TemplateVersionID)
require.NoError(t, err)
inv, root := clitest.New(t, "start", ws.Workspace.Name,
"--template-version", version.Name, "-y")
clitest.SetupConfig(t, member, root)
err = inv.Run()
require.Error(t, err)
require.Contains(t, err.Error(), "wait for the current build to finish")
after := coderdtest.MustWorkspace(t, client, ws.Workspace.ID)
require.Equal(t, ws.Build.ID, after.LatestBuild.ID)
})
}
}
func TestUpdateStopTemplateVersionFailedStop(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)
const failure = "stop apply failed"
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete, ProvisionInit: echo.InitComplete,
ProvisionPlan: echo.PlanComplete, ProvisionGraph: echo.GraphComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{
proto.WorkspaceTransition_STOP: {
{Type: &proto.Response_Apply{Apply: &proto.ApplyComplete{Error: failure}}},
},
},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
stopTarget := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil,
func(req *codersdk.CreateTemplateVersionRequest) { req.TemplateID = template.ID })
coderdtest.AwaitTemplateVersionJobCompleted(t, client, stopTarget.ID)
ctx := testutil.Context(t, testutil.WaitLong)
stop, err := member.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stop.ID)
before := coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, codersdk.WorkspaceTransitionStop, before.LatestBuild.Transition)
require.Equal(t, codersdk.WorkspaceStatusFailed, before.LatestBuild.Status)
inv, root := clitest.New(t, "update", workspace.Name, "--stop-template-version", stopTarget.Name,
"--template-version", version.Name, "--use-parameter-defaults", "-y")
clitest.SetupConfig(t, member, root)
err = inv.Run()
require.ErrorContains(t, err, "the latest build is a stop (failed)")
require.ErrorContains(t, err, "coder stop --template-version "+stopTarget.Name)
after := coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, before.LatestBuild.ID, after.LatestBuild.ID)
}