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:
Steven Masley
2024-07-12 10:47:28 -10:00
committed by GitHub
parent e4aef272fa
commit 9cbe2b27e7
9 changed files with 312 additions and 16 deletions

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -29,6 +30,8 @@ func (r *RootCmd) create() *serpent.Command {
parameterFlags workspaceParameterFlags parameterFlags workspaceParameterFlags
autoUpdates string autoUpdates string
copyParametersFrom string copyParametersFrom string
// Organization context is only required if more than 1 template
// shares the same name across multiple organizations.
orgContext = NewOrganizationContext() orgContext = NewOrganizationContext()
) )
client := new(codersdk.Client) client := new(codersdk.Client)
@ -44,11 +47,7 @@ func (r *RootCmd) create() *serpent.Command {
), ),
Middleware: serpent.Chain(r.InitClient(client)), Middleware: serpent.Chain(r.InitClient(client)),
Handler: func(inv *serpent.Invocation) error { Handler: func(inv *serpent.Invocation) error {
organization, err := orgContext.Selected(inv, client) var err error
if err != nil {
return err
}
workspaceOwner := codersdk.Me workspaceOwner := codersdk.Me
if len(inv.Args) >= 1 { if len(inv.Args) >= 1 {
workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0]) workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0])
@ -99,7 +98,7 @@ func (r *RootCmd) create() *serpent.Command {
if templateName == "" { if templateName == "" {
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:")) _, _ = 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 { if err != nil {
return err return err
} }
@ -111,13 +110,28 @@ func (r *RootCmd) create() *serpent.Command {
templateNames := make([]string, 0, len(templates)) templateNames := make([]string, 0, len(templates))
templateByName := make(map[string]codersdk.Template, 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 { for _, template := range templates {
templateName := template.Name templateName := template.Name
if len(uniqueOrganizations) > 1 {
templateName += cliui.Placeholder(
fmt.Sprintf(
" (%s)",
template.OrganizationName,
),
)
}
if template.ActiveUserCount > 0 { if template.ActiveUserCount > 0 {
templateName += cliui.Placeholder( templateName += cliui.Placeholder(
fmt.Sprintf( fmt.Sprintf(
" (used by %s)", " used by %s",
formatActiveDevelopers(template.ActiveUserCount), formatActiveDevelopers(template.ActiveUserCount),
), ),
) )
@ -145,13 +159,65 @@ func (r *RootCmd) create() *serpent.Command {
} }
templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID
} else { } else {
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName) templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
ExactName: templateName,
})
if err != nil { if err != nil {
return xerrors.Errorf("get template by name: %w", err) 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 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 var schedSpec *string
if startAt != "" { if startAt != "" {
sched, err := parseCLISchedule(startAt) sched, err := parseCLISchedule(startAt)
@ -207,7 +273,7 @@ func (r *RootCmd) create() *serpent.Command {
ttlMillis = ptr.Ref(stopAfter.Milliseconds()) 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, TemplateVersionID: templateVersionID,
Name: workspaceName, Name: workspaceName,
AutostartSchedule: schedSpec, AutostartSchedule: schedSpec,

View File

@ -641,9 +641,10 @@ func NewOrganizationContext() *OrganizationContext {
return &OrganizationContext{} return &OrganizationContext{}
} }
func (*OrganizationContext) optionName() string { return "Organization" }
func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) { func (o *OrganizationContext) AttachOptions(cmd *serpent.Command) {
cmd.Options = append(cmd.Options, serpent.Option{ cmd.Options = append(cmd.Options, serpent.Option{
Name: "Organization", Name: o.optionName(),
Description: "Select which organization (uuid or name) to use.", Description: "Select which organization (uuid or name) to use.",
// Only required if the user is a part of more than 1 organization. // Only required if the user is a part of more than 1 organization.
// Otherwise, we can assume a default value. // 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) { func (o *OrganizationContext) Selected(inv *serpent.Invocation, client *codersdk.Client) (codersdk.Organization, error) {
// Fetch the set of organizations the user is a member of. // Fetch the set of organizations the user is a member of.
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me) orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)

View File

@ -160,7 +160,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
RequireActiveVersion: requireActiveVersion, RequireActiveVersion: requireActiveVersion,
} }
_, err = client.CreateTemplate(inv.Context(), organization.ID, createReq) template, err := client.CreateTemplate(inv.Context(), organization.ID, createReq)
if err != nil { if err != nil {
return err return err
} }
@ -171,7 +171,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))+"! "+ pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp))+"! "+
"Developers can provision a workspace with this template using:")+"\n") "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) _, _ = fmt.Fprintln(inv.Stdout)
return nil return nil
@ -244,6 +244,7 @@ func (r *RootCmd) templateCreate() *serpent.Command {
cliui.SkipPromptOption(), cliui.SkipPromptOption(),
} }
orgContext.AttachOptions(cmd)
cmd.Options = append(cmd.Options, uploadFlags.options()...) cmd.Options = append(cmd.Options, uploadFlags.options()...)
return cmd return cmd
} }

View File

@ -7,6 +7,9 @@ USAGE:
flag flag
OPTIONS: OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
--default-ttl duration (default: 24h) --default-ttl duration (default: 24h)
Specify a default TTL for workspaces created from this template. It is Specify a default TTL for workspaces created from this template. It is
the default time before shutdown - workspaces created from this the default time before shutdown - workspaces created from this

View File

@ -199,8 +199,7 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G
parser := httpapi.NewQueryParamParser() parser := httpapi.NewQueryParamParser()
filter := database.GetTemplatesWithFilterParams{ filter := database.GetTemplatesWithFilterParams{
Deleted: parser.Boolean(values, false, "deleted"), Deleted: parser.Boolean(values, false, "deleted"),
// TODO: Should name be a fuzzy search? ExactName: parser.String(values, "", "exact_name"),
ExactName: parser.String(values, "", "name"),
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
} }

View File

@ -365,6 +365,7 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
type TemplateFilter struct { type TemplateFilter struct {
OrganizationID uuid.UUID OrganizationID uuid.UUID
ExactName string
} }
// asRequestOption returns a function that can be used in (*Client).Request. // 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())) 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 := r.URL.Query()
q.Set("q", strings.Join(params, " ")) q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()

View File

@ -105,6 +105,15 @@ Requires workspace builds to use the active template version. This setting does
Bypass prompts. Bypass prompts.
### -O, --org
| | |
| ----------- | -------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_ORGANIZATION</code> |
Select which organization (uuid or name) to use.
### -d, --directory ### -d, --directory
| | | | | |

View 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"))
})
}

View File

@ -1204,6 +1204,7 @@ export interface TemplateExample {
// From codersdk/organizations.go // From codersdk/organizations.go
export interface TemplateFilter { export interface TemplateFilter {
readonly OrganizationID: string; readonly OrganizationID: string;
readonly ExactName: string;
} }
// From codersdk/templates.go // From codersdk/templates.go