Skip to content

Commit 9cbe2b2

Browse files
authored
chore: create workspaces and templates for multiple orgs (coder#13866)
* chore: creating workspaces and templates to work with orgs * handle wrong org selected * create org member in coderdtest helper
1 parent e4aef27 commit 9cbe2b2

File tree

9 files changed

+312
-16
lines changed

9 files changed

+312
-16
lines changed

cli/create.go

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"strings"
78
"time"
89

910
"github.com/google/uuid"
@@ -29,7 +30,9 @@ func (r *RootCmd) create() *serpent.Command {
2930
parameterFlags workspaceParameterFlags
3031
autoUpdates string
3132
copyParametersFrom string
32-
orgContext = NewOrganizationContext()
33+
// Organization context is only required if more than 1 template
34+
// shares the same name across multiple organizations.
35+
orgContext = NewOrganizationContext()
3336
)
3437
client := new(codersdk.Client)
3538
cmd := &serpent.Command{
@@ -44,11 +47,7 @@ func (r *RootCmd) create() *serpent.Command {
4447
),
4548
Middleware: serpent.Chain(r.InitClient(client)),
4649
Handler: func(inv *serpent.Invocation) error {
47-
organization, err := orgContext.Selected(inv, client)
48-
if err != nil {
49-
return err
50-
}
51-
50+
var err error
5251
workspaceOwner := codersdk.Me
5352
if len(inv.Args) >= 1 {
5453
workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0])
@@ -99,7 +98,7 @@ func (r *RootCmd) create() *serpent.Command {
9998
if templateName == "" {
10099
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
101100

102-
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
101+
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
103102
if err != nil {
104103
return err
105104
}
@@ -111,13 +110,28 @@ func (r *RootCmd) create() *serpent.Command {
111110
templateNames := make([]string, 0, len(templates))
112111
templateByName := make(map[string]codersdk.Template, len(templates))
113112

113+
// If more than 1 organization exists in the list of templates,
114+
// then include the organization name in the select options.
115+
uniqueOrganizations := make(map[uuid.UUID]bool)
116+
for _, template := range templates {
117+
uniqueOrganizations[template.OrganizationID] = true
118+
}
119+
114120
for _, template := range templates {
115121
templateName := template.Name
122+
if len(uniqueOrganizations) > 1 {
123+
templateName += cliui.Placeholder(
124+
fmt.Sprintf(
125+
" (%s)",
126+
template.OrganizationName,
127+
),
128+
)
129+
}
116130

117131
if template.ActiveUserCount > 0 {
118132
templateName += cliui.Placeholder(
119133
fmt.Sprintf(
120-
" (used by %s)",
134+
" used by %s",
121135
formatActiveDevelopers(template.ActiveUserCount),
122136
),
123137
)
@@ -145,13 +159,65 @@ func (r *RootCmd) create() *serpent.Command {
145159
}
146160
templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID
147161
} else {
148-
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName)
162+
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
163+
ExactName: templateName,
164+
})
149165
if err != nil {
150166
return xerrors.Errorf("get template by name: %w", err)
151167
}
168+
if len(templates) == 0 {
169+
return xerrors.Errorf("no template found with the name %q", templateName)
170+
}
171+
172+
if len(templates) > 1 {
173+
templateOrgs := []string{}
174+
for _, tpl := range templates {
175+
templateOrgs = append(templateOrgs, tpl.OrganizationName)
176+
}
177+
178+
selectedOrg, err := orgContext.Selected(inv, client)
179+
if err != nil {
180+
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, ", "))
181+
}
182+
183+
index := slices.IndexFunc(templates, func(i codersdk.Template) bool {
184+
return i.OrganizationID == selectedOrg.ID
185+
})
186+
if index == -1 {
187+
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, ", "))
188+
}
189+
190+
// remake the list with the only template selected
191+
templates = []codersdk.Template{templates[index]}
192+
}
193+
194+
template = templates[0]
152195
templateVersionID = template.ActiveVersionID
153196
}
154197

198+
// If the user specified an organization via a flag or env var, the template **must**
199+
// be in that organization. Otherwise, we should throw an error.
200+
orgValue, orgValueSource := orgContext.ValueSource(inv)
201+
if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) {
202+
selectedOrg, err := orgContext.Selected(inv, client)
203+
if err != nil {
204+
return err
205+
}
206+
207+
if template.OrganizationID != selectedOrg.ID {
208+
orgNameFormat := "'--org=%q'"
209+
if orgValueSource == serpent.ValueSourceEnv {
210+
orgNameFormat = "CODER_ORGANIZATION=%q"
211+
}
212+
213+
return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template",
214+
template.OrganizationName,
215+
fmt.Sprintf(orgNameFormat, selectedOrg.Name),
216+
fmt.Sprintf(orgNameFormat, template.OrganizationName),
217+
)
218+
}
219+
}
220+
155221
var schedSpec *string
156222
if startAt != "" {
157223
sched, err := parseCLISchedule(startAt)
@@ -207,7 +273,7 @@ func (r *RootCmd) create() *serpent.Command {
207273
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
208274
}
209275

210-
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{
276+
workspace, err := client.CreateWorkspace(inv.Context(), template.OrganizationID, workspaceOwner, codersdk.CreateWorkspaceRequest{
211277
TemplateVersionID: templateVersionID,
212278
Name: workspaceName,
213279
AutostartSchedule: schedSpec,

cli/root.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,9 +641,10 @@ func NewOrganizationContext() *OrganizationContext {
641641
return &OrganizationContext{}
642642
}
643643

644+
func (*OrganizationContext) optionName() string { return "Organization" }
644645
func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
645646
cmd.Options = append(cmd.Options, serpent.Option{
646-
Name: "Organization",
647+
Name: o.optionName(),
647648
Description: "Select which organization (uuid or name) to use.",
648649
// Only required if the user is a part of more than 1 organization.
649650
// Otherwise, we can assume a default value.
@@ -655,6 +656,14 @@ func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
655656
})
656657
}
657658

659+
func (o *OrganizationContext) ValueSource(inv *serpent.Invocation) (string, serpent.ValueSource) {
660+
opt := inv.Command.Options.ByName(o.optionName())
661+
if opt == nil {
662+
return o.FlagSelect, serpent.ValueSourceNone
663+
}
664+
return o.FlagSelect, opt.ValueSource
665+
}
666+
658667
func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
659668
// Fetch the set of organizations the user is a member of.
660669
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)

cli/templatecreate.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
160160
RequireActiveVersion: requireActiveVersion,
161161
}
162162

163-
_, err = client.CreateTemplate(inv.Context(), organization.ID, createReq)
163+
template, err := client.CreateTemplate(inv.Context(), organization.ID, createReq)
164164
if err != nil {
165165
return err
166166
}
@@ -171,7 +171,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
171171
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))+"! "+
172172
"Developers can provision a workspace with this template using:")+"\n")
173173

174-
_, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q [workspace name]", templateName)))
174+
_, _ = fmt.Fprintln(inv.Stdout, " "+pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("coder create --template=%q --org=%q [workspace name]", templateName, template.OrganizationName)))
175175
_, _ = fmt.Fprintln(inv.Stdout)
176176

177177
return nil
@@ -244,6 +244,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
244244

245245
cliui.SkipPromptOption(),
246246
}
247+
orgContext.AttachOptions(cmd)
247248
cmd.Options = append(cmd.Options, uploadFlags.options()...)
248249
return cmd
249250
}

cli/testdata/coder_templates_create_--help.golden

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ USAGE:
77
flag
88

99
OPTIONS:
10+
-O, --org string, $CODER_ORGANIZATION
11+
Select which organization (uuid or name) to use.
12+
1013
--default-ttl duration (default: 24h)
1114
Specify a default TTL for workspaces created from this template. It is
1215
the default time before shutdown - workspaces created from this

coderd/searchquery/search.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,8 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G
198198

199199
parser := httpapi.NewQueryParamParser()
200200
filter := database.GetTemplatesWithFilterParams{
201-
Deleted: parser.Boolean(values, false, "deleted"),
202-
// TODO: Should name be a fuzzy search?
203-
ExactName: parser.String(values, "", "name"),
201+
Deleted: parser.Boolean(values, false, "deleted"),
202+
ExactName: parser.String(values, "", "exact_name"),
204203
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
205204
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
206205
}

codersdk/organizations.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
365365

366366
type TemplateFilter struct {
367367
OrganizationID uuid.UUID
368+
ExactName string
368369
}
369370

370371
// asRequestOption returns a function that can be used in (*Client).Request.
@@ -378,6 +379,10 @@ func (f TemplateFilter) asRequestOption() RequestOption {
378379
params = append(params, fmt.Sprintf("organization:%q", f.OrganizationID.String()))
379380
}
380381

382+
if f.ExactName != "" {
383+
params = append(params, fmt.Sprintf("exact_name:%q", f.ExactName))
384+
}
385+
381386
q := r.URL.Query()
382387
q.Set("q", strings.Join(params, " "))
383388
r.URL.RawQuery = q.Encode()

docs/cli/templates_create.md

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)