mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()...)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user