mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
chore: create workspaces and templates for multiple orgs (#13866)
* chore: creating workspaces and templates to work with orgs * handle wrong org selected * create org member in coderdtest helper
This commit is contained in:
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@ -29,6 +30,8 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
// Organization context is only required if more than 1 template
|
||||
// shares the same name across multiple organizations.
|
||||
orgContext = NewOrganizationContext()
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
@ -44,11 +47,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
),
|
||||
Middleware: serpent.Chain(r.InitClient(client)),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
organization, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
workspaceOwner := codersdk.Me
|
||||
if len(inv.Args) >= 1 {
|
||||
workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0])
|
||||
@ -99,7 +98,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
if templateName == "" {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
|
||||
|
||||
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
|
||||
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -111,13 +110,28 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
templateNames := make([]string, 0, len(templates))
|
||||
templateByName := make(map[string]codersdk.Template, len(templates))
|
||||
|
||||
// If more than 1 organization exists in the list of templates,
|
||||
// then include the organization name in the select options.
|
||||
uniqueOrganizations := make(map[uuid.UUID]bool)
|
||||
for _, template := range templates {
|
||||
uniqueOrganizations[template.OrganizationID] = true
|
||||
}
|
||||
|
||||
for _, template := range templates {
|
||||
templateName := template.Name
|
||||
if len(uniqueOrganizations) > 1 {
|
||||
templateName += cliui.Placeholder(
|
||||
fmt.Sprintf(
|
||||
" (%s)",
|
||||
template.OrganizationName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if template.ActiveUserCount > 0 {
|
||||
templateName += cliui.Placeholder(
|
||||
fmt.Sprintf(
|
||||
" (used by %s)",
|
||||
" used by %s",
|
||||
formatActiveDevelopers(template.ActiveUserCount),
|
||||
),
|
||||
)
|
||||
@ -145,13 +159,65 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
}
|
||||
templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID
|
||||
} else {
|
||||
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName)
|
||||
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
|
||||
ExactName: templateName,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
if len(templates) == 0 {
|
||||
return xerrors.Errorf("no template found with the name %q", templateName)
|
||||
}
|
||||
|
||||
if len(templates) > 1 {
|
||||
templateOrgs := []string{}
|
||||
for _, tpl := range templates {
|
||||
templateOrgs = append(templateOrgs, tpl.OrganizationName)
|
||||
}
|
||||
|
||||
selectedOrg, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("multiple templates found with the name %q, use `--org=<organization_name>` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", "))
|
||||
}
|
||||
|
||||
index := slices.IndexFunc(templates, func(i codersdk.Template) bool {
|
||||
return i.OrganizationID == selectedOrg.ID
|
||||
})
|
||||
if index == -1 {
|
||||
return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org=<organization_name> to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", "))
|
||||
}
|
||||
|
||||
// remake the list with the only template selected
|
||||
templates = []codersdk.Template{templates[index]}
|
||||
}
|
||||
|
||||
template = templates[0]
|
||||
templateVersionID = template.ActiveVersionID
|
||||
}
|
||||
|
||||
// If the user specified an organization via a flag or env var, the template **must**
|
||||
// be in that organization. Otherwise, we should throw an error.
|
||||
orgValue, orgValueSource := orgContext.ValueSource(inv)
|
||||
if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) {
|
||||
selectedOrg, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if template.OrganizationID != selectedOrg.ID {
|
||||
orgNameFormat := "'--org=%q'"
|
||||
if orgValueSource == serpent.ValueSourceEnv {
|
||||
orgNameFormat = "CODER_ORGANIZATION=%q"
|
||||
}
|
||||
|
||||
return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template",
|
||||
template.OrganizationName,
|
||||
fmt.Sprintf(orgNameFormat, selectedOrg.Name),
|
||||
fmt.Sprintf(orgNameFormat, template.OrganizationName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var schedSpec *string
|
||||
if startAt != "" {
|
||||
sched, err := parseCLISchedule(startAt)
|
||||
@ -207,7 +273,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{
|
||||
workspace, err := client.CreateWorkspace(inv.Context(), template.OrganizationID, workspaceOwner, codersdk.CreateWorkspaceRequest{
|
||||
TemplateVersionID: templateVersionID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
|
11
cli/root.go
11
cli/root.go
@ -641,9 +641,10 @@ func NewOrganizationContext() *OrganizationContext {
|
||||
return &OrganizationContext{}
|
||||
}
|
||||
|
||||
func (*OrganizationContext) optionName() string { return "Organization" }
|
||||
func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
|
||||
cmd.Options = append(cmd.Options, serpent.Option{
|
||||
Name: "Organization",
|
||||
Name: o.optionName(),
|
||||
Description: "Select which organization (uuid or name) to use.",
|
||||
// Only required if the user is a part of more than 1 organization.
|
||||
// Otherwise, we can assume a default value.
|
||||
@ -655,6 +656,14 @@ func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
|
||||
})
|
||||
}
|
||||
|
||||
func (o *OrganizationContext) ValueSource(inv *serpent.Invocation) (string, serpent.ValueSource) {
|
||||
opt := inv.Command.Options.ByName(o.optionName())
|
||||
if opt == nil {
|
||||
return o.FlagSelect, serpent.ValueSourceNone
|
||||
}
|
||||
return o.FlagSelect, opt.ValueSource
|
||||
}
|
||||
|
||||
func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
|
||||
// Fetch the set of organizations the user is a member of.
|
||||
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
|
||||
|
@ -160,7 +160,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
RequireActiveVersion: requireActiveVersion,
|
||||
}
|
||||
|
||||
_, err = client.CreateTemplate(inv.Context(), organization.ID, createReq)
|
||||
template, err := client.CreateTemplate(inv.Context(), organization.ID, createReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -171,7 +171,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))+"! "+
|
||||
"Developers can provision a workspace with this template using:")+"\n")
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q [workspace name]", templateName)))
|
||||
_, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q --org=%q [workspace name]", templateName, template.OrganizationName)))
|
||||
_, _ = fmt.Fprintln(inv.Stdout)
|
||||
|
||||
return nil
|
||||
@ -244,6 +244,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
|
||||
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
cmd.Options = append(cmd.Options, uploadFlags.options()...)
|
||||
return cmd
|
||||
}
|
||||
|
@ -7,6 +7,9 @@ USAGE:
|
||||
flag
|
||||
|
||||
OPTIONS:
|
||||
-O, --org string, $CODER_ORGANIZATION
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
--default-ttl duration (default: 24h)
|
||||
Specify a default TTL for workspaces created from this template. It is
|
||||
the default time before shutdown - workspaces created from this
|
||||
|
@ -199,8 +199,7 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G
|
||||
parser := httpapi.NewQueryParamParser()
|
||||
filter := database.GetTemplatesWithFilterParams{
|
||||
Deleted: parser.Boolean(values, false, "deleted"),
|
||||
// TODO: Should name be a fuzzy search?
|
||||
ExactName: parser.String(values, "", "name"),
|
||||
ExactName: parser.String(values, "", "exact_name"),
|
||||
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
|
||||
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
|
||||
}
|
||||
|
@ -365,6 +365,7 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
|
||||
|
||||
type TemplateFilter struct {
|
||||
OrganizationID uuid.UUID
|
||||
ExactName string
|
||||
}
|
||||
|
||||
// asRequestOption returns a function that can be used in (*Client).Request.
|
||||
@ -378,6 +379,10 @@ func (f TemplateFilter) asRequestOption() RequestOption {
|
||||
params = append(params, fmt.Sprintf("organization:%q", f.OrganizationID.String()))
|
||||
}
|
||||
|
||||
if f.ExactName != "" {
|
||||
params = append(params, fmt.Sprintf("exact_name:%q", f.ExactName))
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
q.Set("q", strings.Join(params, " "))
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
9
docs/cli/templates_create.md
generated
9
docs/cli/templates_create.md
generated
@ -105,6 +105,15 @@ Requires workspace builds to use the active template version. This setting does
|
||||
|
||||
Bypass prompts.
|
||||
|
||||
### -O, --org
|
||||
|
||||
| | |
|
||||
| ----------- | -------------------------------- |
|
||||
| Type | <code>string</code> |
|
||||
| Environment | <code>$CODER_ORGANIZATION</code> |
|
||||
|
||||
Select which organization (uuid or name) to use.
|
||||
|
||||
### -d, --directory
|
||||
|
||||
| | |
|
||||
|
203
enterprise/cli/create_test.go
Normal file
203
enterprise/cli/create_test.go
Normal file
@ -0,0 +1,203 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
)
|
||||
|
||||
func TestEnterpriseCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type setupData struct {
|
||||
firstResponse codersdk.CreateFirstUserResponse
|
||||
second codersdk.Organization
|
||||
owner *codersdk.Client
|
||||
member *codersdk.Client
|
||||
}
|
||||
|
||||
type setupArgs struct {
|
||||
firstTemplates []string
|
||||
secondTemplates []string
|
||||
}
|
||||
|
||||
// setupMultipleOrganizations creates an extra organization, assigns a member
|
||||
// both organizations, and optionally creates templates in each organization.
|
||||
setupMultipleOrganizations := func(t *testing.T, args setupArgs) setupData {
|
||||
ownerClient, first := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
// This only affects the first org.
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureExternalProvisionerDaemons: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
second := coderdtest.CreateOrganization(t, ownerClient, coderdtest.CreateOrganizationOptions{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
member, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.ScopedRoleOrgMember(second.ID))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
createTemplate := func(tplName string, orgID uuid.UUID) {
|
||||
version := coderdtest.CreateTemplateVersion(t, ownerClient, orgID, nil)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version.ID)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
coderdtest.CreateTemplate(t, ownerClient, orgID, version.ID, func(request *codersdk.CreateTemplateRequest) {
|
||||
request.Name = tplName
|
||||
})
|
||||
}
|
||||
|
||||
for _, tplName := range args.firstTemplates {
|
||||
createTemplate(tplName, first.OrganizationID)
|
||||
}
|
||||
|
||||
for _, tplName := range args.secondTemplates {
|
||||
createTemplate(tplName, second.ID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return setupData{
|
||||
firstResponse: first,
|
||||
owner: ownerClient,
|
||||
second: second,
|
||||
member: member,
|
||||
}
|
||||
}
|
||||
|
||||
// Test creating a workspace in the second organization with a template
|
||||
// name.
|
||||
t.Run("CreateMultipleOrganization", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const templateName = "secondtemplate"
|
||||
setup := setupMultipleOrganizations(t, setupArgs{
|
||||
secondTemplates: []string{templateName},
|
||||
})
|
||||
member := setup.member
|
||||
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"-y",
|
||||
"--template", templateName,
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
_ = ptytest.New(t).Attach(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, templateName)
|
||||
assert.Equal(t, ws.OrganizationName, setup.second.Name, "workspace in second organization")
|
||||
}
|
||||
})
|
||||
|
||||
// If a template name exists in two organizations, the workspace create will
|
||||
// fail.
|
||||
t.Run("AmbiguousTemplateName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const templateName = "ambiguous"
|
||||
setup := setupMultipleOrganizations(t, setupArgs{
|
||||
firstTemplates: []string{templateName},
|
||||
secondTemplates: []string{templateName},
|
||||
})
|
||||
member := setup.member
|
||||
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"-y",
|
||||
"--template", templateName,
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
_ = ptytest.New(t).Attach(inv)
|
||||
err := inv.Run()
|
||||
require.Error(t, err, "expected error due to ambiguous template name")
|
||||
require.ErrorContains(t, err, "multiple templates found")
|
||||
})
|
||||
|
||||
// Ambiguous template names are allowed if the organization is specified.
|
||||
t.Run("WorkingAmbiguousTemplateName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const templateName = "ambiguous"
|
||||
setup := setupMultipleOrganizations(t, setupArgs{
|
||||
firstTemplates: []string{templateName},
|
||||
secondTemplates: []string{templateName},
|
||||
})
|
||||
member := setup.member
|
||||
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"-y",
|
||||
"--template", templateName,
|
||||
"--org", setup.second.Name,
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
_ = ptytest.New(t).Attach(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
if assert.NoError(t, err, "expected workspace to be created") {
|
||||
assert.Equal(t, ws.TemplateName, templateName)
|
||||
assert.Equal(t, ws.OrganizationName, setup.second.Name, "workspace in second organization")
|
||||
}
|
||||
})
|
||||
|
||||
// If an organization is specified, but the template is not in that
|
||||
// organization, an error is thrown.
|
||||
t.Run("CreateIncorrectOrg", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const templateName = "secondtemplate"
|
||||
setup := setupMultipleOrganizations(t, setupArgs{
|
||||
firstTemplates: []string{templateName},
|
||||
})
|
||||
member := setup.member
|
||||
|
||||
args := []string{
|
||||
"create",
|
||||
"my-workspace",
|
||||
"-y",
|
||||
"--org", setup.second.Name,
|
||||
"--template", templateName,
|
||||
}
|
||||
inv, root := clitest.New(t, args...)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
_ = ptytest.New(t).Attach(inv)
|
||||
err := inv.Run()
|
||||
require.Error(t, err)
|
||||
// The error message should indicate the flag to fix the issue.
|
||||
require.ErrorContains(t, err, fmt.Sprintf("--org=%q", "first-organization"))
|
||||
})
|
||||
}
|
1
site/src/api/typesGenerated.ts
generated
1
site/src/api/typesGenerated.ts
generated
@ -1204,6 +1204,7 @@ export interface TemplateExample {
|
||||
// From codersdk/organizations.go
|
||||
export interface TemplateFilter {
|
||||
readonly OrganizationID: string;
|
||||
readonly ExactName: string;
|
||||
}
|
||||
|
||||
// From codersdk/templates.go
|
||||
|
Reference in New Issue
Block a user