diff --git a/cli/create.go b/cli/create.go index df60fdd72d345..bdf805ee26d69 100644 --- a/cli/create.go +++ b/cli/create.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "time" "github.com/google/uuid" @@ -29,7 +30,9 @@ func (r *RootCmd) create() *serpent.Command { parameterFlags workspaceParameterFlags autoUpdates string copyParametersFrom string - orgContext = NewOrganizationContext() + // Organization context is only required if more than 1 template + // shares the same name across multiple organizations. + orgContext = NewOrganizationContext() ) client := new(codersdk.Client) cmd := &serpent.Command{ @@ -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=` 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= 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, diff --git a/cli/root.go b/cli/root.go index 579f3c4c29202..22d153c00f7f1 100644 --- a/cli/root.go +++ b/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) diff --git a/cli/templatecreate.go b/cli/templatecreate.go index e70a7bba03a8b..c6d81f3f53822 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -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 } diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index be37480655f04..5bb7bb96b6899 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -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 diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 0744ec8482926..2ad2a04f57356 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -198,9 +198,8 @@ 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"), + Deleted: parser.Boolean(values, false, "deleted"), + ExactName: parser.String(values, "", "exact_name"), IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index ecc87ccb983ce..4cb59482d15e7 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -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() diff --git a/docs/cli/templates_create.md b/docs/cli/templates_create.md index de15a9fb905f8..c2ab11bd4916f 100644 --- a/docs/cli/templates_create.md +++ b/docs/cli/templates_create.md @@ -105,6 +105,15 @@ Requires workspace builds to use the active template version. This setting does Bypass prompts. +### -O, --org + +| | | +| ----------- | -------------------------------- | +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. + ### -d, --directory | | | diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go new file mode 100644 index 0000000000000..085ecdf93ca4d --- /dev/null +++ b/enterprise/cli/create_test.go @@ -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")) + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 70318955fd18f..e4f81b703c181 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1203,6 +1203,7 @@ export interface TemplateExample { // From codersdk/organizations.go export interface TemplateFilter { readonly OrganizationID: string; + readonly ExactName: string; } // From codersdk/templates.go