feat: accept template version IDs in --template-version (#30563)

Follow-up to #30199. `--template-version` on `coder create`, `start`,
`stop`, and `update` (plus `--stop-template-version` on `update`) now
takes a template version ID as well as a name. Passing a version UUID
used to 404 with a pretty unhelpful `get template version by name`
error.

`resolveTemplateVersionID` still looks up by name first, so a version
that happens to be named like a UUID resolves the same way it did
before. If that 404s and the input parses as a UUID, it falls back to
fetching the version by ID. Either way the version has to belong to the
selected template. That check matters most for `create`, which only
sends the version ID and lets the server derive the template from it, so
without it `coder create --template foo --template-version <id of a bar
version>` would quietly create a `bar` workspace.

The name is now path-escaped before the lookup. Previously something
like `../../../templateversions/<id>` could reach other API routes
through the name lookup and skip the template check entirely.

Not-found errors now read `template version "x" not found for template
"foo"` instead of the raw SDK error, so anything matching on the old
text will need updating. Flag help and the generated CLI docs say "name
or ID" now.
This commit is contained in:
dylanhuff-at-coder
2026-10-14 08:52:36 -07:00
committed by GitHub
parent 200cffcfd8
commit c856d5fb22
16 changed files with 184 additions and 37 deletions
+2 -2
View File
@@ -222,7 +222,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
}
if len(templateVersion) > 0 {
versionID, err := resolveTemplateVersionID(inv.Context(), client, template.ID, templateVersion)
versionID, err := resolveTemplateVersionID(inv.Context(), client, template.ID, template.Name, templateVersion)
if err != nil {
return err
}
@@ -424,7 +424,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
serpent.Option{
Flag: "template-version",
Env: "CODER_TEMPLATE_VERSION",
Description: "Specify a template version name.",
Description: "Specify a template version name or ID.",
Value: serpent.StringOf(&templateVersion),
},
serpent.Option{
+40 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
@@ -40,7 +41,7 @@ func (r *RootCmd) start() *serpent.Command {
},
{
Flag: "template-version",
Description: "Start with a named version of the workspace template.",
Description: "Start with a version of the workspace template, by name or ID.",
Value: serpent.StringOf(&templateVersion),
},
cliui.SkipPromptOption(),
@@ -58,7 +59,7 @@ func (r *RootCmd) start() *serpent.Command {
if err != nil {
return err
}
versionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, templateVersion)
versionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, workspace.TemplateName, templateVersion)
if err != nil {
return err
}
@@ -248,15 +249,46 @@ func startWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace
return build, nil
}
// resolveTemplateVersionID looks up a template version by name. An empty name
// returns uuid.Nil so callers can fall back to their default version.
func resolveTemplateVersionID(ctx context.Context, client *codersdk.Client, templateID uuid.UUID, name string) (uuid.UUID, error) {
if name == "" {
// resolveTemplateVersionID looks up a template version of the given template
// by name or ID. Names take precedence, so a version named like a UUID is
// matched by name. An empty input returns uuid.Nil so callers can fall back to
// their default version.
func resolveTemplateVersionID(ctx context.Context, client *codersdk.Client, templateID uuid.UUID, templateName, nameOrID string) (uuid.UUID, error) {
if nameOrID == "" {
return uuid.Nil, nil
}
version, err := client.TemplateVersionByName(ctx, templateID, name)
notFound := xerrors.Errorf("template version %q not found for template %q", nameOrID, templateName)
if nameOrID == "." || nameOrID == ".." {
return uuid.Nil, notFound
}
// Escaping keeps the input from adding path segments or a query.
// "." and ".." are rejected above.
version, err := client.TemplateVersionByName(ctx, templateID, url.PathEscape(nameOrID))
if err != nil {
return uuid.Nil, xerrors.Errorf("get template version by name: %w", err)
if !isNotFoundError(err) {
return uuid.Nil, xerrors.Errorf("get template version by name: %w", err)
}
id, parseErr := uuid.Parse(nameOrID)
if parseErr != nil {
return uuid.Nil, notFound
}
version, err = client.TemplateVersion(ctx, id)
if err != nil {
if isNotFoundError(err) {
return uuid.Nil, notFound
}
return uuid.Nil, xerrors.Errorf("get template version by ID: %w", err)
}
}
// The ID lookup can return any template's version, so check ownership
// for it and for anything the name route returns.
if version.TemplateID == nil || *version.TemplateID != templateID {
return uuid.Nil, xerrors.Errorf("template version %q does not belong to template %q", nameOrID, templateName)
}
return version.ID, nil
}
func isNotFoundError(err error) bool {
sdkErr, ok := codersdk.AsError(err)
return ok && sdkErr.StatusCode() == http.StatusNotFound
}
+2 -2
View File
@@ -25,7 +25,7 @@ func (r *RootCmd) stop() *serpent.Command {
Options: serpent.OptionSet{
{
Flag: "template-version",
Description: "Stop with a named version of the workspace template. Defaults to the workspace's current version.",
Description: "Stop with a version of the workspace template, by name or ID. Defaults to the workspace's current version.",
Value: serpent.StringOf(&templateVersion),
},
cliui.SkipPromptOption(),
@@ -52,7 +52,7 @@ func (r *RootCmd) stop() *serpent.Command {
return err
}
versionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, templateVersion)
versionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, workspace.TemplateName, templateVersion)
if err != nil {
return err
}
+1 -1
View File
@@ -72,7 +72,7 @@ OPTIONS:
Specify a template name.
--template-version string, $CODER_TEMPLATE_VERSION
Specify a template version name.
Specify a template version name or ID.
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
Automatically accept parameter defaults when no value is provided.
+1 -1
View File
@@ -42,7 +42,7 @@ OPTIONS:
pairs for the parameters.
--template-version string
Start with a named version of the workspace template.
Start with a version of the workspace template, by name or ID.
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
Automatically accept parameter defaults when no value is provided.
+2 -2
View File
@@ -7,8 +7,8 @@ USAGE:
OPTIONS:
--template-version string
Stop with a named version of the workspace template. Defaults to the
workspace's current version.
Stop with a version of the workspace template, by name or ID. Defaults
to the workspace's current version.
-y, --yes bool
Bypass confirmation prompts.
+5 -4
View File
@@ -42,12 +42,13 @@ OPTIONS:
pairs for the parameters.
--stop-template-version string
Stop with a named version of the workspace template. Only applies when
the workspace is started. Defaults to the workspace's current version.
Stop with a version of the workspace template, by name or ID. Only
applies when the workspace is started. Defaults to the workspace's
current version.
--template-version string
Start with a named version of the workspace template. Defaults to the
active version.
Start with a version of the workspace template, by name or ID.
Defaults to the active version.
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
Automatically accept parameter defaults when no value is provided.
+4 -4
View File
@@ -42,11 +42,11 @@ func (r *RootCmd) update() *serpent.Command {
if err != nil {
return err
}
versionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, templateVersion)
versionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, workspace.TemplateName, templateVersion)
if err != nil {
return err
}
stopVersionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, stopTemplateVersion)
stopVersionID, err := resolveTemplateVersionID(inv.Context(), client, workspace.TemplateID, workspace.TemplateName, stopTemplateVersion)
if err != nil {
return err
}
@@ -131,11 +131,11 @@ func (r *RootCmd) update() *serpent.Command {
cmd.Options = append(cmd.Options, serpent.Option{
Flag: "template-version",
Description: "Start with a named version of the workspace template. Defaults to the active version.",
Description: "Start with a version of the workspace template, by name or ID. Defaults to the active version.",
Value: serpent.StringOf(&templateVersion),
}, serpent.Option{
Flag: "stop-template-version",
Description: "Stop with a named version of the workspace template. Only applies when the workspace is started. Defaults to the workspace's current version.",
Description: "Stop with a version of the workspace template, by name or ID. Only applies when the workspace is started. Defaults to the workspace's current version.",
Value: serpent.StringOf(&stopTemplateVersion),
})
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
+119 -5
View File
@@ -2,7 +2,9 @@ package cli_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/google/uuid"
@@ -30,6 +32,7 @@ func TestWorkspaceBuildTemplateVersion(t *testing.T) {
stopped bool
sameVersion bool
missing bool
byID bool
wantErr string
}{
{name: "Stop", command: "stop", startPin: true},
@@ -41,12 +44,20 @@ func TestWorkspaceBuildTemplateVersion(t *testing.T) {
{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},
{name: "StopByID", command: "stop", startPin: true, byID: true},
{name: "StartByID", command: "start", startPin: true, byID: true},
{name: "UpdateBothByID", command: "update", startPin: true, stopPin: true, byID: true},
{name: "StartMissingName", command: "start", startPin: true, missing: true},
{name: "UpdateMissingStart", command: "update", startPin: true, stopPin: true, missing: true, byID: true},
{name: "UpdateMissingStop", command: "update", stopPin: true, missing: true, byID: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
missingVersion := "missing-version"
if tc.byID {
missingVersion = uuid.NewString()
}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
@@ -87,20 +98,26 @@ func TestWorkspaceBuildTemplateVersion(t *testing.T) {
startID := version.ID
if tc.startPin {
name := target.Name
if tc.byID {
name = target.ID.String()
}
startID = target.ID
if tc.sameVersion {
name = version.Name
startID = version.ID
}
if tc.missing {
name = uuid.NewString()
name = missingVersion
}
args = append(args, "--template-version", name)
}
if tc.stopPin {
name := stopTarget.Name
if tc.byID {
name = stopTarget.ID.String()
}
if tc.missing && !tc.startPin {
name = uuid.NewString()
name = missingVersion
}
args = append(args, "--stop-template-version", name)
}
@@ -125,7 +142,7 @@ func TestWorkspaceBuildTemplateVersion(t *testing.T) {
}
if tc.missing {
require.Error(t, err)
require.Contains(t, err.Error(), "get template version by name")
require.ErrorContains(t, err, fmt.Sprintf("not found for template %q", template.Name))
after, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, before, after.LatestBuild.BuildNumber)
@@ -387,3 +404,100 @@ func TestUpdateStopTemplateVersionFailedStop(t *testing.T) {
after := coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, before.LatestBuild.ID, after.LatestBuild.ID)
}
func TestTemplateVersionNameOrID(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)
createVersion := func(mutators ...func(*codersdk.CreateTemplateVersionRequest)) codersdk.TemplateVersion {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil, mutators...)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
return version
}
active := createVersion()
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, active.ID)
inTemplate := func(req *codersdk.CreateTemplateVersionRequest) { req.TemplateID = template.ID }
target := createVersion(inTemplate)
// Named after the active version's ID, so that ID resolves to this
// version by name.
namedLikeID := createVersion(func(req *codersdk.CreateTemplateVersionRequest) {
req.TemplateID = template.ID
req.Name = active.ID.String()
})
otherVersion := createVersion()
coderdtest.CreateTemplate(t, client, owner.OrganizationID, otherVersion.ID)
// A file whose body decodes as a version of this template that points at
// the other template's version.
forged, err := json.Marshal(codersdk.TemplateVersion{ID: otherVersion.ID, TemplateID: &template.ID})
require.NoError(t, err)
forgedFile, err := member.Upload(testutil.Context(t, testutil.WaitShort), codersdk.ContentTypeTar, bytes.NewReader(forged))
require.NoError(t, err)
// Error formats take the --template-version input and the template name.
const (
notFound = "template version %q not found for template %q"
notOwned = "template version %q does not belong to template %q"
)
for _, command := range []string{"create", "start", "stop"} {
for _, tc := range []struct {
name string
version string
wantID uuid.UUID
wantErr string
}{
{name: "ID", version: target.ID.String(), wantID: target.ID},
{name: "NameBeforeID", version: active.ID.String(), wantID: namedLikeID.ID},
{name: "OtherTemplateID", version: otherVersion.ID.String(), wantErr: notOwned},
{name: "OtherTemplatePathName", version: "../../../templateversions/" + otherVersion.ID.String(), wantErr: notFound},
{name: "ForgedFilePathName", version: "../../../files/" + forgedFile.ID.String(), wantErr: notFound},
{name: "ParentSegment", version: "..", wantErr: notFound},
{name: "CurrentSegment", version: ".", wantErr: notFound},
{name: "MissingID", version: uuid.NewString(), wantErr: notFound},
{name: "MissingName", version: "missing-version", wantErr: notFound},
} {
t.Run(command+"/"+tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
workspaceName := testutil.GetRandomNameHyphenated(t)
args := []string{command, workspaceName, "--template-version", tc.version, "-y"}
var before codersdk.Workspace
if command == "create" {
args = append(args, "--template", template.Name)
} else {
before = coderdtest.CreateWorkspace(t, member, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.Name = workspaceName
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, before.LatestBuild.ID)
}
if command != "stop" {
args = append(args, "--use-parameter-defaults")
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
err := inv.Run()
if tc.wantErr != "" {
require.ErrorContains(t, err, fmt.Sprintf(tc.wantErr, tc.version, template.Name))
if command == "create" {
_, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
return
}
after := coderdtest.MustWorkspace(t, member, before.ID)
require.Equal(t, before.LatestBuild.ID, after.LatestBuild.ID)
return
}
require.NoError(t, err)
after, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
require.NoError(t, err)
require.Equal(t, template.ID, after.TemplateID)
require.Equal(t, tc.wantID, after.LatestBuild.TemplateVersionID)
})
}
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ Specify a template name.
| Type | <code>string</code> |
| Environment | <code>$CODER_TEMPLATE_VERSION</code> |
Specify a template version name.
Specify a template version name or ID.
### --preset
+1 -1
View File
@@ -40,7 +40,7 @@ Specify a template name.
| Type | <code>string</code> |
| Environment | <code>$CODER_TEMPLATE_VERSION</code> |
Specify a template version name.
Specify a template version name or ID.
### --preset
+1 -1
View File
@@ -30,7 +30,7 @@ Return immediately after starting the workspace.
|------|---------------------|
| Type | <code>string</code> |
Start with a named version of the workspace template.
Start with a version of the workspace template, by name or ID.
### -y, --yes
+1 -1
View File
@@ -22,7 +22,7 @@ coder stop [flags] <workspace>
|------|---------------------|
| Type | <code>string</code> |
Stop with a named version of the workspace template. Defaults to the workspace's current version.
Stop with a version of the workspace template, by name or ID. Defaults to the workspace's current version.
### -y, --yes
+2 -2
View File
@@ -36,7 +36,7 @@ Bypass confirmation prompts.
|------|---------------------|
| Type | <code>string</code> |
Start with a named version of the workspace template. Defaults to the active version.
Start with a version of the workspace template, by name or ID. Defaults to the active version.
### --stop-template-version
@@ -44,7 +44,7 @@ Start with a named version of the workspace template. Defaults to the active ver
|------|---------------------|
| Type | <code>string</code> |
Stop with a named version of the workspace template. Only applies when the workspace is started. Defaults to the workspace's current version.
Stop with a version of the workspace template, by name or ID. Only applies when the workspace is started. Defaults to the workspace's current version.
### --build-option
+1 -1
View File
@@ -115,7 +115,7 @@ coder update -y <workspace-name>
```
By default, `coder update` stops the workspace with its current template version and starts it on the active version.
To start on a specific named version instead, use `--template-version`.
To start on a specific version instead, use `--template-version` with the version name or ID.
To stop with a different version than the workspace's current one, use `--stop-template-version`:
```sh
@@ -72,7 +72,7 @@ OPTIONS:
Specify a template name.
--template-version string, $CODER_TEMPLATE_VERSION
Specify a template version name.
Specify a template version name or ID.
--use-parameter-defaults bool, $CODER_WORKSPACE_USE_PARAMETER_DEFAULTS
Automatically accept parameter defaults when no value is provided.