//go:build !slim package cli import ( "context" "fmt" "io" "os/signal" "strconv" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/scaletest/createusers" "github.com/coder/coder/v2/scaletest/harness" "github.com/coder/coder/v2/scaletest/loadtestutil" "github.com/coder/serpent" ) func (r *RootCmd) scaletestCreateUsers() *serpent.Command { var ( count int64 templateAdminPercentage float64 noCleanup bool tracingFlags = &scaletestTracingFlags{} strategy = &scaletestStrategyFlags{} cleanupStrategy = newScaletestCleanupStrategy() output = &scaletestOutputFlags{} ) cmd := &serpent.Command{ Use: "create-users", Short: "Create many users for use by other scale tests.", Long: "Pre-provision a fixed number of users (optionally promoting a percentage to " + "template admin) so that other load generators can target existing users instead " + "of creating their own inline. Intended to be run against dedicated, throwaway " + "scaletest deployments.", Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() client, err := r.InitClient(inv) if err != nil { return err } notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...) defer stop() ctx = notifyCtx me, err := RequireAdmin(ctx, client) if err != nil { return err } if count <= 0 { return xerrors.New("--count must be greater than 0") } if templateAdminPercentage < 0 || templateAdminPercentage > 100 { return xerrors.New("--template-admin-percentage must be between 0 and 100") } // int64 truncates toward zero, so this floors the count. Ensure at // least one admin whenever a nonzero percentage was requested. templateAdminCount := int64(float64(count) * templateAdminPercentage / 100) if templateAdminPercentage > 0 { templateAdminCount = max(templateAdminCount, 1) } regularUserCount := count - templateAdminCount _, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n") _, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", count) _, _ = fmt.Fprintf(inv.Stderr, " Template admins: %d (%.1f%%)\n", templateAdminCount, templateAdminPercentage) _, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-templateAdminPercentage) outputs, err := output.parse() if err != nil { return xerrors.Errorf("could not parse --output flags") } tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx) if err != nil { return xerrors.Errorf("create tracer provider: %w", err) } defer func() { _, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...") if err := closeTracing(ctx); err != nil { _, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err) } }() tracer := tracerProvider.Tracer(scaletestTracerName) th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy()) _, _ = fmt.Fprintln(inv.Stderr, "Creating users...") for i := range count { id := strconv.FormatInt(i, 10) name := fmt.Sprintf("createusers-%s", id) var roles []string if i < templateAdminCount { roles = []string{codersdk.RoleTemplateAdmin} } // Use an independent client per runner so they don't reuse TCP // connections, which can unbalance requests among Coder replicas. runnerClient, err := loadtestutil.DupClientCopyingHeaders(client, BypassHeader) if err != nil { return xerrors.Errorf("create runner client: %w", err) } // Username and email are generated by createusers when left empty. config := createusers.Config{ OrganizationID: me.OrganizationIDs[0], } if err := config.Validate(); err != nil { return xerrors.Errorf("validate config: %w", err) } var runner harness.Runnable = &createUsersRunner{ client: runnerClient, runner: createusers.NewRunner(runnerClient, config), roles: roles, } if tracingEnabled { runner = &runnableTraceWrapper{ tracer: tracer, spanName: name, runner: runner, } } th.AddRun(name, id, runner) } _, _ = fmt.Fprintln(inv.Stderr, "Running user creation scaletest...") testCtx, testCancel := strategy.toContext(ctx) defer testCancel() err = th.Run(testCtx) if err != nil { return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err) } // If the command was interrupted, skip stats. if notifyCtx.Err() != nil { return notifyCtx.Err() } res := th.Results() for _, o := range outputs { err = o.write(res, inv.Stdout) if err != nil { return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err) } } if noCleanup { _, _ = fmt.Fprintln(inv.Stderr, "\nSkipping cleanup (users left in place for other scale tests).") } else { _, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...") cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx) defer cleanupCancel() err = th.Cleanup(cleanupCtx) if err != nil { return xerrors.Errorf("cleanup tests: %w", err) } } if res.TotalFail > 0 { return xerrors.New("load test failed, see above for more details") } return nil }, } cmd.Options = serpent.OptionSet{ { Flag: "count", FlagShorthand: "c", Env: "CODER_SCALETEST_CREATE_USERS_COUNT", Description: "Required: Total number of users to create.", Value: serpent.Int64Of(&count), Required: true, }, { Flag: "template-admin-percentage", Env: "CODER_SCALETEST_CREATE_USERS_TEMPLATE_ADMIN_PERCENTAGE", Default: "20.0", Description: "Percentage of users to assign the Template Admin role to (0-100).", Value: serpent.Float64Of(&templateAdminPercentage), }, { Flag: "no-cleanup", Env: "CODER_SCALETEST_NO_CLEANUP", Description: "Do not clean up resources after the test completes. Use this when other scale tests will reuse the created users.", Value: serpent.BoolOf(&noCleanup), }, } tracingFlags.attach(&cmd.Options) strategy.attach(&cmd.Options) cleanupStrategy.attach(&cmd.Options) output.attach(&cmd.Options) return cmd } // createUsersRunner adapts createusers.Runner to harness.Runnable and assigns // roles after creation. The client must be a site admin to assign roles. type createUsersRunner struct { client *codersdk.Client runner *createusers.Runner roles []string } var ( _ harness.Runnable = &createUsersRunner{} _ harness.Cleanable = &createUsersRunner{} ) func (r *createUsersRunner) Run(ctx context.Context, id string, logs io.Writer) error { ctx, span := tracing.StartSpan(ctx) defer span.End() user, err := r.runner.RunReturningUser(ctx, id, logs) if err != nil { return xerrors.Errorf("create user: %w", err) } if len(r.roles) > 0 { _, _ = fmt.Fprintf(logs, "Assigning roles to user %q: %v\n", user.Username, r.roles) if _, err := r.client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ Roles: r.roles, }); err != nil { return xerrors.Errorf("assign roles %v to user %q: %w", r.roles, user.Username, err) } } return nil } func (r *createUsersRunner) Cleanup(ctx context.Context, id string, logs io.Writer) error { return r.runner.Cleanup(ctx, id, logs) }