From c4d356ff71f91650996108592b67ec51a966299e Mon Sep 17 00:00:00 2001 From: Callum Styan Date: Tue, 25 Aug 2026 12:48:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20--username-infix=20selection?= =?UTF-8?q?=20to=20scaletest=20notifications=E2=80=A6=20(#28515)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/exp_scaletest.go | 32 +++++++++---- cli/exp_scaletest_internal_test.go | 77 ++++++++++++++++++++++++++++++ cli/exp_scaletest_notifications.go | 35 +++++++++++--- 3 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 cli/exp_scaletest_internal_test.go diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index b0c79f0a049..4df597aaabf 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -2271,6 +2271,14 @@ func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, } func getScaletestUsers(ctx context.Context, client *codersdk.Client) ([]codersdk.User, error) { + return getScaletestUsersWithPrefix(ctx, client, loadtestutil.ScaleTestPrefix+"-") +} + +// getScaletestUsersWithPrefix returns scaletest users whose username starts with +// the given full prefix. The prefix partitions users into disjoint pools (for +// example per load generator) so that concurrent reuse runs select +// non-overlapping users. +func getScaletestUsersWithPrefix(ctx context.Context, client *codersdk.Client, prefix string) ([]codersdk.User, error) { var ( pageNumber = 0 limit = 100 @@ -2279,7 +2287,7 @@ func getScaletestUsers(ctx context.Context, client *codersdk.Client) ([]codersdk for { page, err := client.Users(ctx, codersdk.UsersRequest{ - Search: "scaletest-", + Search: prefix, Pagination: codersdk.Pagination{ Offset: pageNumber * limit, Limit: limit, @@ -2294,18 +2302,26 @@ func getScaletestUsers(ctx context.Context, client *codersdk.Client) ([]codersdk break } - pageUsers := make([]codersdk.User, 0, len(page.Users)) - for _, u := range page.Users { - if loadtestutil.IsScaleTestUser(u.Username, u.Email) { - pageUsers = append(pageUsers, u) - } - } - users = append(users, pageUsers...) + users = append(users, filterScaletestUsersByPrefix(page.Users, prefix)...) } return users, nil } +// filterScaletestUsersByPrefix returns the users whose username starts with +// prefix and that look like scaletest users. It is the in-memory selection +// behind getScaletestUsersWithPrefix. The username prefix guard matters because +// the users search matches the term in several fields, not just the username. +func filterScaletestUsersByPrefix(users []codersdk.User, prefix string) []codersdk.User { + filtered := make([]codersdk.User, 0, len(users)) + for _, u := range users { + if strings.HasPrefix(u.Username, prefix) && loadtestutil.IsScaleTestUser(u.Username, u.Email) { + filtered = append(filtered, u) + } + } + return filtered +} + func parseTemplate(ctx context.Context, client *codersdk.Client, organizationIDs []uuid.UUID, template string) (tpl codersdk.Template, err error) { if id, err := uuid.Parse(template); err == nil && id != uuid.Nil { tpl, err = client.Template(ctx, id) diff --git a/cli/exp_scaletest_internal_test.go b/cli/exp_scaletest_internal_test.go new file mode 100644 index 00000000000..6742b710dbe --- /dev/null +++ b/cli/exp_scaletest_internal_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/scaletest/loadtestutil" +) + +// TestFilterScaletestUsersByPrefix covers the pure user-selection logic behind +// notifications --reuse-users: an infix pool must not pick up users from the +// default pool (isolation), non-scaletest users are ignored, and the username +// guard rejects users that only match the prefix in another field. +func TestFilterScaletestUsersByPrefix(t *testing.T) { + t.Parallel() + + users := []codersdk.User{ + scaletestUser("scaletest-notif-aaaaaaaa-0", "aaaaaaaa-0@scaletest.local"), + scaletestUser("scaletest-notif-bbbbbbbb-1", "bbbbbbbb-1@scaletest.local"), + // Default pool: a scaletest user that is NOT in the notif pool. + scaletestUser("scaletest-cccccccc-0", "cccccccc-0@scaletest.local"), + // Not a scaletest user at all. + scaletestUser("regular-user", "regular@example.com"), + // Scaletest email but a username that does not start with the prefix; the + // guard must reject it even though a search could surface it. + scaletestUser("admin", "scaletest-notif-dddddddd-9@scaletest.local"), + } + + cases := []struct { + name string + prefix string + want []string + }{ + { + name: "infix isolates its own pool", + prefix: "scaletest-notif-", + want: []string{ + "scaletest-notif-aaaaaaaa-0", + "scaletest-notif-bbbbbbbb-1", + }, + }, + { + name: "default prefix selects all scaletest users", + prefix: loadtestutil.ScaleTestPrefix + "-", + want: []string{ + "scaletest-notif-aaaaaaaa-0", + "scaletest-notif-bbbbbbbb-1", + "scaletest-cccccccc-0", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := filterScaletestUsersByPrefix(users, tc.prefix) + + gotNames := make([]string, 0, len(got)) + for _, u := range got { + gotNames = append(gotNames, u.Username) + } + require.ElementsMatch(t, tc.want, gotNames) + }) + } +} + +func scaletestUser(username, email string) codersdk.User { + return codersdk.User{ + ReducedUser: codersdk.ReducedUser{ + MinimalUser: codersdk.MinimalUser{Username: username}, + Email: email, + }, + } +} diff --git a/cli/exp_scaletest_notifications.go b/cli/exp_scaletest_notifications.go index 918729ddcd5..c10c26924de 100644 --- a/cli/exp_scaletest_notifications.go +++ b/cli/exp_scaletest_notifications.go @@ -37,6 +37,7 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command { dialTimeout time.Duration noCleanup bool reuseUsers bool + usernameInfix string smtpAPIURL string tracingFlags = &scaletestTracingFlags{} @@ -124,7 +125,7 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command { // Bound token lifetime to just beyond the run so tokens orphaned by a // hard kill expire quickly rather than at the deployment default. tokenLifetime := notificationTimeout + dialTimeout + time.Hour - adminReuse, regularReuse, err = selectNotificationReuseUsers(ctx, client, int(templateAdminCount), int(regularUserCount), tokenLifetime) + adminReuse, regularReuse, err = selectNotificationReuseUsers(ctx, client, usernameInfix, int(templateAdminCount), int(regularUserCount), tokenLifetime) if err != nil { return err } @@ -327,6 +328,12 @@ func (r *RootCmd) scaletestNotifications() *serpent.Command { Description: "Reuse existing scaletest users instead of creating new ones. Enough users, including template admins, must already exist (see \"coder exp scaletest create-users\") or the command errors. Run only one user-selecting scaletest command at a time to avoid overlapping selections.", Value: serpent.BoolOf(&reuseUsers), }, + { + Flag: "username-infix", + Env: "CODER_SCALETEST_NOTIFICATION_USERNAME_INFIX", + Description: "Username infix identifying the user pool to reuse when --reuse-users is set. It must match the --username-infix used by the create-users run that provisioned the pool: for example asdf selects users named scaletest-asdf-- so notifications reuses only its own pool and does not compete with other load generators. Leave empty to select any scaletest- user. Ignored without --reuse-users.", + Value: serpent.StringOf(&usernameInfix), + }, { Flag: "smtp-api-url", Env: "CODER_SCALETEST_SMTP_API_URL", @@ -506,10 +513,20 @@ type notificationReuseUser struct { sessionToken string } -// selectNotificationReuseUsers selects existing scaletest users and mints a token -// for each, erroring if the pool lacks enough template admins or regular users. -func selectNotificationReuseUsers(ctx context.Context, client *codersdk.Client, adminCount, regularCount int, tokenLifetime time.Duration) (admins, regulars []notificationReuseUser, err error) { - users, err := getScaletestUsers(ctx, client) +// selectNotificationReuseUsers selects existing scaletest users belonging to the +// pool identified by usernameInfix and mints a token for each, erroring if the +// pool lacks enough template admins or regular users. usernameInfix is inserted +// between the mandatory "scaletest-" root and the rest of the username (empty +// selects any scaletest- user), so it isolates this run from other load +// generators that reuse scaletest users concurrently. +func selectNotificationReuseUsers(ctx context.Context, client *codersdk.Client, usernameInfix string, adminCount, regularCount int, tokenLifetime time.Duration) (admins, regulars []notificationReuseUser, err error) { + // The scaletest- root is always kept so selection matches the users that + // create-users provisions; usernameInfix, when set, narrows to that pool. + searchPrefix := loadtestutil.ScaleTestPrefix + "-" + if usernameInfix != "" { + searchPrefix += usernameInfix + "-" + } + users, err := getScaletestUsersWithPrefix(ctx, client, searchPrefix) if err != nil { return nil, nil, xerrors.Errorf("list scaletest users: %w", err) } @@ -529,10 +546,14 @@ func selectNotificationReuseUsers(ctx context.Context, client *codersdk.Client, if total > 0 { pct = float64(adminCount) / float64(total) * 100 } + hint := fmt.Sprintf("coder exp scaletest create-users --count %d --template-admin-percentage %.2f --no-cleanup", total, pct) + if usernameInfix != "" { + hint = fmt.Sprintf("coder exp scaletest create-users --count %d --template-admin-percentage %.2f --username-infix %q --no-cleanup", total, pct, usernameInfix) + } return nil, nil, xerrors.Errorf( "not enough scaletest users to reuse: found %d template admins and %d regular users, need %d and %d. "+ - "Create them first, for example: coder exp scaletest create-users --count %d --template-admin-percentage %.2f --no-cleanup", - len(adminUsers), len(regularUsers), adminCount, regularCount, total, pct) + "Create them first, for example: %s", + len(adminUsers), len(regularUsers), adminCount, regularCount, hint) } // Token names are auto-generated by the server; we only need the returned key.