diff --git a/cli/server.go b/cli/server.go index b29b39b05fb4a..f19c0df86fd1b 100644 --- a/cli/server.go +++ b/cli/server.go @@ -212,10 +212,16 @@ func enablePrometheus( options.PrometheusRegistry.MustRegister(collectors.NewGoCollector()) options.PrometheusRegistry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) - closeUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.PrometheusRegistry, options.Database, 0) + closeActiveUsersFunc, err := prometheusmetrics.ActiveUsers(ctx, options.Logger.Named("active_user_metrics"), options.PrometheusRegistry, options.Database, 0) if err != nil { return nil, xerrors.Errorf("register active users prometheus metric: %w", err) } + afterCtx(ctx, closeActiveUsersFunc) + + closeUsersFunc, err := prometheusmetrics.Users(ctx, options.Logger.Named("user_metrics"), quartz.NewReal(), options.PrometheusRegistry, options.Database, 0) + if err != nil { + return nil, xerrors.Errorf("register users prometheus metric: %w", err) + } afterCtx(ctx, closeUsersFunc) closeWorkspacesFunc, err := prometheusmetrics.Workspaces(ctx, options.Logger.Named("workspaces_metrics"), options.PrometheusRegistry, options.Database, 0) diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 0619688468554..7ef95e7e093e6 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -197,6 +197,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { UpdatedAt: dbtime.Now(), RBACRoles: []string{rbac.RoleOwner().String()}, LoginType: database.LoginTypePassword, + Status: "", }) if err != nil { return xerrors.Errorf("insert user: %w", err) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index d5c26d98115cb..cd647d0537a93 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -106,6 +106,58 @@ Use a YAML configuration file when your server launch become unwieldy. Write out the current server config as YAML to stdout. +EMAIL OPTIONS: +Configure how emails are sent. + + --email-force-tls bool, $CODER_EMAIL_FORCE_TLS (default: false) + Force a TLS connection to the configured SMTP smarthost. + + --email-from string, $CODER_EMAIL_FROM + The sender's address to use. + + --email-hello string, $CODER_EMAIL_HELLO (default: localhost) + The hostname identifying the SMTP server. + + --email-smarthost host:port, $CODER_EMAIL_SMARTHOST (default: localhost:587) + The intermediary SMTP host through which emails are sent. + +EMAIL / EMAIL AUTHENTICATION OPTIONS: +Configure SMTP authentication options. + + --email-auth-identity string, $CODER_EMAIL_AUTH_IDENTITY + Identity to use with PLAIN authentication. + + --email-auth-password string, $CODER_EMAIL_AUTH_PASSWORD + Password to use with PLAIN/LOGIN authentication. + + --email-auth-password-file string, $CODER_EMAIL_AUTH_PASSWORD_FILE + File from which to load password for use with PLAIN/LOGIN + authentication. + + --email-auth-username string, $CODER_EMAIL_AUTH_USERNAME + Username to use with PLAIN/LOGIN authentication. + +EMAIL / EMAIL TLS OPTIONS: +Configure TLS for your SMTP server target. + + --email-tls-ca-cert-file string, $CODER_EMAIL_TLS_CACERTFILE + CA certificate file to use. + + --email-tls-cert-file string, $CODER_EMAIL_TLS_CERTFILE + Certificate file to use. + + --email-tls-cert-key-file string, $CODER_EMAIL_TLS_CERTKEYFILE + Certificate key file to use. + + --email-tls-server-name string, $CODER_EMAIL_TLS_SERVERNAME + Server name to verify against the target certificate. + + --email-tls-skip-verify bool, $CODER_EMAIL_TLS_SKIPVERIFY + Skip verification of the target server's certificate (insecure). + + --email-tls-starttls bool, $CODER_EMAIL_TLS_STARTTLS + Enable STARTTLS to upgrade insecure SMTP connections using TLS. + INTROSPECTION / HEALTH CHECK OPTIONS: --health-check-refresh duration, $CODER_HEALTH_CHECK_REFRESH (default: 10m0s) Refresh interval for healthchecks. @@ -349,54 +401,68 @@ Configure how notifications are processed and delivered. NOTIFICATIONS / EMAIL OPTIONS: Configure how email notifications are sent. - --notifications-email-force-tls bool, $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS (default: false) + --notifications-email-force-tls bool, $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS Force a TLS connection to the configured SMTP smarthost. + DEPRECATED: Use --email-force-tls instead. --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM The sender's address to use. + DEPRECATED: Use --email-from instead. - --notifications-email-hello string, $CODER_NOTIFICATIONS_EMAIL_HELLO (default: localhost) + --notifications-email-hello string, $CODER_NOTIFICATIONS_EMAIL_HELLO The hostname identifying the SMTP server. + DEPRECATED: Use --email-hello instead. - --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST (default: localhost:587) + --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST The intermediary SMTP host through which emails are sent. + DEPRECATED: Use --email-smarthost instead. NOTIFICATIONS / EMAIL / EMAIL AUTHENTICATION OPTIONS: Configure SMTP authentication options. --notifications-email-auth-identity string, $CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY Identity to use with PLAIN authentication. + DEPRECATED: Use --email-auth-identity instead. --notifications-email-auth-password string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD Password to use with PLAIN/LOGIN authentication. + DEPRECATED: Use --email-auth-password instead. --notifications-email-auth-password-file string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE File from which to load password for use with PLAIN/LOGIN authentication. + DEPRECATED: Use --email-auth-password-file instead. --notifications-email-auth-username string, $CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME Username to use with PLAIN/LOGIN authentication. + DEPRECATED: Use --email-auth-username instead. NOTIFICATIONS / EMAIL / EMAIL TLS OPTIONS: Configure TLS for your SMTP server target. --notifications-email-tls-ca-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE CA certificate file to use. + DEPRECATED: Use --email-tls-ca-cert-file instead. --notifications-email-tls-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE Certificate file to use. + DEPRECATED: Use --email-tls-cert-file instead. --notifications-email-tls-cert-key-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE Certificate key file to use. + DEPRECATED: Use --email-tls-cert-key-file instead. --notifications-email-tls-server-name string, $CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME Server name to verify against the target certificate. + DEPRECATED: Use --email-tls-server-name instead. --notifications-email-tls-skip-verify bool, $CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY Skip verification of the target server's certificate (insecure). + DEPRECATED: Use --email-tls-skip-verify instead. --notifications-email-tls-starttls bool, $CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS Enable STARTTLS to upgrade insecure SMTP connections using TLS. + DEPRECATED: Use --email-tls-starttls instead. NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 95486a26344b8..38b2b68c24de1 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -518,6 +518,51 @@ userQuietHoursSchedule: # compatibility reasons, this will be removed in a future release. # (default: false, type: bool) allowWorkspaceRenames: false +# Configure how emails are sent. +email: + # The sender's address to use. + # (default: , type: string) + from: "" + # The intermediary SMTP host through which emails are sent. + # (default: localhost:587, type: host:port) + smarthost: localhost:587 + # The hostname identifying the SMTP server. + # (default: localhost, type: string) + hello: localhost + # Force a TLS connection to the configured SMTP smarthost. + # (default: false, type: bool) + forceTLS: false + # Configure SMTP authentication options. + emailAuth: + # Identity to use with PLAIN authentication. + # (default: , type: string) + identity: "" + # Username to use with PLAIN/LOGIN authentication. + # (default: , type: string) + username: "" + # File from which to load password for use with PLAIN/LOGIN authentication. + # (default: , type: string) + passwordFile: "" + # Configure TLS for your SMTP server target. + emailTLS: + # Enable STARTTLS to upgrade insecure SMTP connections using TLS. + # (default: , type: bool) + startTLS: false + # Server name to verify against the target certificate. + # (default: , type: string) + serverName: "" + # Skip verification of the target server's certificate (insecure). + # (default: , type: bool) + insecureSkipVerify: false + # CA certificate file to use. + # (default: , type: string) + caCertFile: "" + # Certificate file to use. + # (default: , type: string) + certFile: "" + # Certificate key file to use. + # (default: , type: string) + certKeyFile: "" # Configure how notifications are processed and delivered. notifications: # Which delivery method to use (available options: 'smtp', 'webhook'). @@ -532,13 +577,13 @@ notifications: # (default: , type: string) from: "" # The intermediary SMTP host through which emails are sent. - # (default: localhost:587, type: host:port) + # (default: , type: host:port) smarthost: localhost:587 # The hostname identifying the SMTP server. - # (default: localhost, type: string) + # (default: , type: string) hello: localhost # Force a TLS connection to the configured SMTP smarthost. - # (default: false, type: bool) + # (default: , type: bool) forceTLS: false # Configure SMTP authentication options. emailAuth: diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 83d1fdc2c492a..27aef82123db8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9896,6 +9896,14 @@ const docTemplate = `{ "password": { "type": "string" }, + "user_status": { + "description": "UserStatus defaults to UserStatusDormant.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.UserStatus" + } + ] + }, "username": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9861e195b7a69..2a19c40d979b3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8809,6 +8809,14 @@ "password": { "type": "string" }, + "user_status": { + "description": "UserStatus defaults to UserStatusDormant.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.UserStatus" + } + ] + }, "username": { "type": "string" } diff --git a/coderd/audit/fields.go b/coderd/audit/fields.go new file mode 100644 index 0000000000000..db0879730425a --- /dev/null +++ b/coderd/audit/fields.go @@ -0,0 +1,33 @@ +package audit + +import ( + "context" + "encoding/json" + + "cdr.dev/slog" +) + +type BackgroundSubsystem string + +const ( + BackgroundSubsystemDormancy BackgroundSubsystem = "dormancy" +) + +func BackgroundTaskFields(subsystem BackgroundSubsystem) map[string]string { + return map[string]string{ + "automatic_actor": "coder", + "automatic_subsystem": string(subsystem), + } +} + +func BackgroundTaskFieldsBytes(ctx context.Context, logger slog.Logger, subsystem BackgroundSubsystem) []byte { + af := BackgroundTaskFields(subsystem) + + wriBytes, err := json.Marshal(af) + if err != nil { + logger.Error(ctx, "marshal additional fields for dormancy audit", slog.Error(err)) + return []byte("{}") + } + + return wriBytes +} diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 88b637384eeda..c8b7bf17b4b96 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -62,12 +62,13 @@ type BackgroundAuditParams[T Auditable] struct { Audit Auditor Log slog.Logger - UserID uuid.UUID - RequestID uuid.UUID - Status int - Action database.AuditAction - OrganizationID uuid.UUID - IP string + UserID uuid.UUID + RequestID uuid.UUID + Status int + Action database.AuditAction + OrganizationID uuid.UUID + IP string + // todo: this should automatically marshal an interface{} instead of accepting a raw message. AdditionalFields json.RawMessage New T diff --git a/coderd/coderd.go b/coderd/coderd.go index bd844d7ca13c3..70101b7020890 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -702,6 +702,7 @@ func New(options *Options) *API { apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ DB: options.Database, + ActivateDormantUser: ActivateDormantUser(options.Logger, &api.Auditor, options.Database), OAuth2Configs: oauthConfigs, RedirectToLogin: false, DisableSessionExpiryRefresh: options.DeploymentValues.Sessions.DisableExpiryRefresh.Value(), diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 47d9a42319d20..f1128f316abde 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -718,6 +718,9 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI Name: RandomName(t), Password: "SomeSecurePassword!", OrganizationIDs: organizationIDs, + // Always create users as active in tests to ignore an extra audit log + // when logging in. + UserStatus: ptr.Ref(codersdk.UserStatusActive), } for _, m := range mutators { m(&req) diff --git a/coderd/database/db.go b/coderd/database/db.go index ae2c31a566cb3..0f923a861efb4 100644 --- a/coderd/database/db.go +++ b/coderd/database/db.go @@ -28,6 +28,7 @@ type Store interface { wrapper Ping(ctx context.Context) (time.Duration, error) + PGLocks(ctx context.Context) (PGLocks, error) InTx(func(Store) error, *TxOptions) error } @@ -48,13 +49,26 @@ type DBTX interface { GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error } +func WithSerialRetryCount(count int) func(*sqlQuerier) { + return func(q *sqlQuerier) { + q.serialRetryCount = count + } +} + // New creates a new database store using a SQL database connection. -func New(sdb *sql.DB) Store { +func New(sdb *sql.DB, opts ...func(*sqlQuerier)) Store { dbx := sqlx.NewDb(sdb, "postgres") - return &sqlQuerier{ + q := &sqlQuerier{ db: dbx, sdb: dbx, + // This is an arbitrary number. + serialRetryCount: 3, + } + + for _, opt := range opts { + opt(q) } + return q } // TxOptions is used to pass some execution metadata to the callers. @@ -104,6 +118,10 @@ type querier interface { type sqlQuerier struct { sdb *sqlx.DB db DBTX + + // serialRetryCount is the number of times to retry a transaction + // if it fails with a serialization error. + serialRetryCount int } func (*sqlQuerier) Wrappers() []string { @@ -143,11 +161,9 @@ func (q *sqlQuerier) InTx(function func(Store) error, txOpts *TxOptions) error { // If we are in a transaction already, the parent InTx call will handle the retry. // We do not want to duplicate those retries. if !inTx && sqlOpts.Isolation == sql.LevelSerializable { - // This is an arbitrarily chosen number. - const retryAmount = 3 var err error attempts := 0 - for attempts = 0; attempts < retryAmount; attempts++ { + for attempts = 0; attempts < q.serialRetryCount; attempts++ { txOpts.executionCount++ err = q.runTx(function, sqlOpts) if err == nil { @@ -203,3 +219,10 @@ func (q *sqlQuerier) runTx(function func(Store) error, txOpts *sql.TxOptions) er } return nil } + +func safeString(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ae6b307b3e7d3..9bf98aade03c4 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -603,6 +603,10 @@ func (q *querier) Ping(ctx context.Context) (time.Duration, error) { return q.db.Ping(ctx) } +func (q *querier) PGLocks(ctx context.Context) (database.PGLocks, error) { + return q.db.PGLocks(ctx) +} + // InTx runs the given function in a transaction. func (q *querier) InTx(function func(querier database.Store) error, txOpts *database.TxOptions) error { return q.db.InTx(func(tx database.Store) error { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 439cf1bdaec19..ae50309e96d66 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -152,7 +152,10 @@ func TestDBAuthzRecursive(t *testing.T) { for i := 2; i < method.Type.NumIn(); i++ { ins = append(ins, reflect.New(method.Type.In(i)).Elem()) } - if method.Name == "InTx" || method.Name == "Ping" || method.Name == "Wrappers" { + if method.Name == "InTx" || + method.Name == "Ping" || + method.Name == "Wrappers" || + method.Name == "PGLocks" { continue } // Log the name of the last method, so if there is a panic, it is diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index df9d551101a25..52e8dd42fea9c 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -34,6 +34,7 @@ var errMatchAny = xerrors.New("match any error") var skipMethods = map[string]string{ "InTx": "Not relevant", "Ping": "Not relevant", + "PGLocks": "Not relevant", "Wrappers": "Not relevant", "AcquireLock": "Not relevant", "TryAcquireLock": "Not relevant", diff --git a/coderd/database/dbfake/builder.go b/coderd/database/dbfake/builder.go new file mode 100644 index 0000000000000..6803374e72445 --- /dev/null +++ b/coderd/database/dbfake/builder.go @@ -0,0 +1,127 @@ +package dbfake + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/testutil" +) + +type OrganizationBuilder struct { + t *testing.T + db database.Store + seed database.Organization + allUsersAllowance int32 + members []uuid.UUID + groups map[database.Group][]uuid.UUID +} + +func Organization(t *testing.T, db database.Store) OrganizationBuilder { + return OrganizationBuilder{ + t: t, + db: db, + members: []uuid.UUID{}, + groups: make(map[database.Group][]uuid.UUID), + } +} + +type OrganizationResponse struct { + Org database.Organization + AllUsersGroup database.Group + Members []database.OrganizationMember + Groups []database.Group +} + +func (b OrganizationBuilder) EveryoneAllowance(allowance int) OrganizationBuilder { + //nolint: revive // returns modified struct + b.allUsersAllowance = int32(allowance) + return b +} + +func (b OrganizationBuilder) Seed(seed database.Organization) OrganizationBuilder { + //nolint: revive // returns modified struct + b.seed = seed + return b +} + +func (b OrganizationBuilder) Members(users ...database.User) OrganizationBuilder { + for _, u := range users { + //nolint: revive // returns modified struct + b.members = append(b.members, u.ID) + } + return b +} + +func (b OrganizationBuilder) Group(seed database.Group, members ...database.User) OrganizationBuilder { + //nolint: revive // returns modified struct + b.groups[seed] = []uuid.UUID{} + for _, u := range members { + //nolint: revive // returns modified struct + b.groups[seed] = append(b.groups[seed], u.ID) + } + return b +} + +func (b OrganizationBuilder) Do() OrganizationResponse { + org := dbgen.Organization(b.t, b.db, b.seed) + + ctx := testutil.Context(b.t, testutil.WaitShort) + //nolint:gocritic // builder code needs perms + ctx = dbauthz.AsSystemRestricted(ctx) + everyone, err := b.db.InsertAllUsersGroup(ctx, org.ID) + require.NoError(b.t, err) + + if b.allUsersAllowance > 0 { + everyone, err = b.db.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ + Name: everyone.Name, + DisplayName: everyone.DisplayName, + AvatarURL: everyone.AvatarURL, + QuotaAllowance: b.allUsersAllowance, + ID: everyone.ID, + }) + require.NoError(b.t, err) + } + + members := make([]database.OrganizationMember, 0) + if len(b.members) > 0 { + for _, u := range b.members { + newMem := dbgen.OrganizationMember(b.t, b.db, database.OrganizationMember{ + UserID: u, + OrganizationID: org.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Roles: nil, + }) + members = append(members, newMem) + } + } + + groups := make([]database.Group, 0) + if len(b.groups) > 0 { + for g, users := range b.groups { + g.OrganizationID = org.ID + group := dbgen.Group(b.t, b.db, g) + groups = append(groups, group) + + for _, u := range users { + dbgen.GroupMember(b.t, b.db, database.GroupMemberTable{ + UserID: u, + GroupID: group.ID, + }) + } + } + } + + return OrganizationResponse{ + Org: org, + AllUsersGroup: everyone, + Members: members, + Groups: groups, + } +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 69419b98c79b1..4ac675309f662 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -342,6 +342,7 @@ func User(t testing.TB, db database.Store, orig database.User) database.User { UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), RBACRoles: takeFirstSlice(orig.RBACRoles, []string{}), LoginType: takeFirst(orig.LoginType, database.LoginTypePassword), + Status: string(takeFirst(orig.Status, database.UserStatusDormant)), }) require.NoError(t, err, "insert user") @@ -407,6 +408,8 @@ func OrganizationMember(t testing.TB, db database.Store, orig database.Organizat } func Group(t testing.TB, db database.Store, orig database.Group) database.Group { + t.Helper() + name := takeFirst(orig.Name, testutil.GetRandomName(t)) group, err := db.InsertGroup(genCtx, database.InsertGroupParams{ ID: takeFirst(orig.ID, uuid.New()), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 4f54598744dd0..6b447f6daacd0 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -339,6 +339,10 @@ func (*FakeQuerier) Ping(_ context.Context) (time.Duration, error) { return 0, nil } +func (*FakeQuerier) PGLocks(_ context.Context) (database.PGLocks, error) { + return []database.PGLock{}, nil +} + func (tx *fakeTx) AcquireLock(_ context.Context, id int64) error { if _, ok := tx.FakeQuerier.locks[id]; ok { return xerrors.Errorf("cannot acquire lock %d: already held", id) @@ -7709,6 +7713,11 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam } } + status := database.UserStatusDormant + if arg.Status != "" { + status = database.UserStatus(arg.Status) + } + user := database.User{ ID: arg.ID, Email: arg.Email, @@ -7717,7 +7726,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam UpdatedAt: arg.UpdatedAt, Username: arg.Username, Name: arg.Name, - Status: database.UserStatusDormant, + Status: status, RBACRoles: arg.RBACRoles, LoginType: arg.LoginType, } @@ -8640,6 +8649,7 @@ func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params dat updated = append(updated, database.UpdateInactiveUsersToDormantRow{ ID: user.ID, Email: user.Email, + Username: user.Username, LastSeenAt: user.LastSeenAt, }) } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 7e74aab3b9de0..e1cfec5bac9ca 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -66,6 +66,13 @@ func (m queryMetricsStore) Ping(ctx context.Context) (time.Duration, error) { return duration, err } +func (m queryMetricsStore) PGLocks(ctx context.Context) (database.PGLocks, error) { + start := time.Now() + locks, err := m.s.PGLocks(ctx) + m.queryLatencies.WithLabelValues("PGLocks").Observe(time.Since(start).Seconds()) + return locks, err +} + func (m queryMetricsStore) InTx(f func(database.Store) error, options *database.TxOptions) error { return m.dbMetrics.InTx(f, options) } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ffc9ab79f777e..27b398a062051 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4299,6 +4299,21 @@ func (mr *MockStoreMockRecorder) OrganizationMembers(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OrganizationMembers", reflect.TypeOf((*MockStore)(nil).OrganizationMembers), arg0, arg1) } +// PGLocks mocks base method. +func (m *MockStore) PGLocks(arg0 context.Context) (database.PGLocks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PGLocks", arg0) + ret0, _ := ret[0].(database.PGLocks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PGLocks indicates an expected call of PGLocks. +func (mr *MockStoreMockRecorder) PGLocks(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PGLocks", reflect.TypeOf((*MockStore)(nil).PGLocks), arg0) +} + // Ping mocks base method. func (m *MockStore) Ping(arg0 context.Context) (time.Duration, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index 327d880f69648..bc8c571795629 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -135,7 +135,8 @@ func NewDB(t testing.TB, opts ...Option) (database.Store, pubsub.Pubsub) { if o.dumpOnFailure { t.Cleanup(func() { DumpOnFailure(t, connectionURL) }) } - db = database.New(sqlDB) + // Unit tests should not retry serial transaction failures. + db = database.New(sqlDB, database.WithSerialRetryCount(1)) ps, err = pubsub.New(context.Background(), o.logger, sqlDB, connectionURL) require.NoError(t, err) diff --git a/coderd/database/dbtestutil/tx.go b/coderd/database/dbtestutil/tx.go new file mode 100644 index 0000000000000..15be63dc35aeb --- /dev/null +++ b/coderd/database/dbtestutil/tx.go @@ -0,0 +1,73 @@ +package dbtestutil + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +type DBTx struct { + database.Store + mu sync.Mutex + done chan error + finalErr chan error +} + +// StartTx starts a transaction and returns a DBTx object. This allows running +// 2 transactions concurrently in a test more easily. +// Example: +// +// a := StartTx(t, db, opts) +// b := StartTx(t, db, opts) +// +// a.GetUsers(...) +// b.GetUsers(...) +// +// require.NoError(t, a.Done() +func StartTx(t *testing.T, db database.Store, opts *database.TxOptions) *DBTx { + done := make(chan error) + finalErr := make(chan error) + txC := make(chan database.Store) + + go func() { + t.Helper() + once := sync.Once{} + count := 0 + + err := db.InTx(func(store database.Store) error { + // InTx can be retried + once.Do(func() { + txC <- store + }) + count++ + if count > 1 { + // If you recursively call InTx, then don't use this. + t.Logf("InTx called more than once: %d", count) + assert.NoError(t, xerrors.New("InTx called more than once, this is not allowed with the StartTx helper")) + } + + <-done + // Just return nil. The caller should be checking their own errors. + return nil + }, opts) + finalErr <- err + }() + + txStore := <-txC + close(txC) + + return &DBTx{Store: txStore, done: done, finalErr: finalErr} +} + +// Done can only be called once. If you call it twice, it will panic. +func (tx *DBTx) Done() error { + tx.mu.Lock() + defer tx.mu.Unlock() + + close(tx.done) + return <-tx.finalErr +} diff --git a/coderd/database/pglocks.go b/coderd/database/pglocks.go new file mode 100644 index 0000000000000..85e1644b3825c --- /dev/null +++ b/coderd/database/pglocks.go @@ -0,0 +1,119 @@ +package database + +import ( + "context" + "fmt" + "reflect" + "sort" + "strings" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/coder/coder/v2/coderd/util/slice" +) + +// PGLock docs see: https://www.postgresql.org/docs/current/view-pg-locks.html#VIEW-PG-LOCKS +type PGLock struct { + // LockType see: https://www.postgresql.org/docs/current/monitoring-stats.html#WAIT-EVENT-LOCK-TABLE + LockType *string `db:"locktype"` + Database *string `db:"database"` // oid + Relation *string `db:"relation"` // oid + RelationName *string `db:"relation_name"` + Page *int `db:"page"` + Tuple *int `db:"tuple"` + VirtualXID *string `db:"virtualxid"` + TransactionID *string `db:"transactionid"` // xid + ClassID *string `db:"classid"` // oid + ObjID *string `db:"objid"` // oid + ObjSubID *int `db:"objsubid"` + VirtualTransaction *string `db:"virtualtransaction"` + PID int `db:"pid"` + Mode *string `db:"mode"` + Granted bool `db:"granted"` + FastPath *bool `db:"fastpath"` + WaitStart *time.Time `db:"waitstart"` +} + +func (l PGLock) Equal(b PGLock) bool { + // Lazy, but hope this works + return reflect.DeepEqual(l, b) +} + +func (l PGLock) String() string { + granted := "granted" + if !l.Granted { + granted = "waiting" + } + var details string + switch safeString(l.LockType) { + case "relation": + details = "" + case "page": + details = fmt.Sprintf("page=%d", *l.Page) + case "tuple": + details = fmt.Sprintf("page=%d tuple=%d", *l.Page, *l.Tuple) + case "virtualxid": + details = "waiting to acquire virtual tx id lock" + default: + details = "???" + } + return fmt.Sprintf("%d-%5s [%s] %s/%s/%s: %s", + l.PID, + safeString(l.TransactionID), + granted, + safeString(l.RelationName), + safeString(l.LockType), + safeString(l.Mode), + details, + ) +} + +// PGLocks returns a list of all locks in the database currently in use. +func (q *sqlQuerier) PGLocks(ctx context.Context) (PGLocks, error) { + rows, err := q.sdb.QueryContext(ctx, ` + SELECT + relation::regclass AS relation_name, + * + FROM pg_locks; + `) + if err != nil { + return nil, err + } + + defer rows.Close() + + var locks []PGLock + err = sqlx.StructScan(rows, &locks) + if err != nil { + return nil, err + } + + return locks, err +} + +type PGLocks []PGLock + +func (l PGLocks) String() string { + // Try to group things together by relation name. + sort.Slice(l, func(i, j int) bool { + return safeString(l[i].RelationName) < safeString(l[j].RelationName) + }) + + var out strings.Builder + for i, lock := range l { + if i != 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString(lock.String()) + } + return out.String() +} + +// Difference returns the difference between two sets of locks. +// This is helpful to determine what changed between the two sets. +func (l PGLocks) Difference(to PGLocks) (new PGLocks, removed PGLocks) { + return slice.SymmetricDifferenceFunc(l, to, func(a, b PGLock) bool { + return a.Equal(b) + }) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d00c4ec3bcdef..ad9986255ca15 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6736,23 +6736,33 @@ const getQuotaConsumedForUser = `-- name: GetQuotaConsumedForUser :one WITH latest_builds AS ( SELECT DISTINCT ON - (workspace_id) id, - workspace_id, - daily_cost + (wb.workspace_id) wb.workspace_id, + wb.daily_cost FROM workspace_builds wb + -- This INNER JOIN prevents a seq scan of the workspace_builds table. + -- Limit the rows to the absolute minimum required, which is all workspaces + -- in a given organization for a given user. +INNER JOIN + workspaces on wb.workspace_id = workspaces.id +WHERE + workspaces.owner_id = $1 AND + workspaces.organization_id = $2 ORDER BY - workspace_id, - created_at DESC + wb.workspace_id, + wb.created_at DESC ) SELECT coalesce(SUM(daily_cost), 0)::BIGINT FROM workspaces -JOIN latest_builds ON +INNER JOIN latest_builds ON latest_builds.workspace_id = workspaces.id -WHERE NOT - deleted AND +WHERE + NOT deleted AND + -- We can likely remove these conditions since we check above. + -- But it does not hurt to be defensive and make sure future query changes + -- do not break anything. workspaces.owner_id = $1 AND workspaces.organization_id = $2 ` @@ -10345,10 +10355,15 @@ INSERT INTO created_at, updated_at, rbac_roles, - login_type + login_type, + status ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, + -- if the status passed in is empty, fallback to dormant, which is what + -- we were doing before. + COALESCE(NULLIF($10::text, '')::user_status, 'dormant'::user_status) + ) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at ` type InsertUserParams struct { @@ -10361,6 +10376,7 @@ type InsertUserParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` LoginType LoginType `db:"login_type" json:"login_type"` + Status string `db:"status" json:"status"` } func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) { @@ -10374,6 +10390,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User arg.UpdatedAt, arg.RBACRoles, arg.LoginType, + arg.Status, ) var i User err := row.Scan( @@ -10408,7 +10425,7 @@ SET WHERE last_seen_at < $2 :: timestamp AND status = 'active'::user_status -RETURNING id, email, last_seen_at +RETURNING id, email, username, last_seen_at ` type UpdateInactiveUsersToDormantParams struct { @@ -10419,6 +10436,7 @@ type UpdateInactiveUsersToDormantParams struct { type UpdateInactiveUsersToDormantRow struct { ID uuid.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` } @@ -10431,7 +10449,12 @@ func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg Updat var items []UpdateInactiveUsersToDormantRow for rows.Next() { var i UpdateInactiveUsersToDormantRow - if err := rows.Scan(&i.ID, &i.Email, &i.LastSeenAt); err != nil { + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.LastSeenAt, + ); err != nil { return nil, err } items = append(items, i) @@ -14947,7 +14970,7 @@ WHERE -- Filter by owner_name AND CASE WHEN $8 :: text != '' THEN - workspaces.owner_id = (SELECT id FROM users WHERE lower(owner_username) = lower($8) AND deleted = false) + workspaces.owner_id = (SELECT id FROM users WHERE lower(users.username) = lower($8) AND deleted = false) ELSE true END -- Filter by template_name diff --git a/coderd/database/queries/quotas.sql b/coderd/database/queries/quotas.sql index 48f9209783e4e..7ab6189dfe8a1 100644 --- a/coderd/database/queries/quotas.sql +++ b/coderd/database/queries/quotas.sql @@ -18,23 +18,33 @@ INNER JOIN groups ON WITH latest_builds AS ( SELECT DISTINCT ON - (workspace_id) id, - workspace_id, - daily_cost + (wb.workspace_id) wb.workspace_id, + wb.daily_cost FROM workspace_builds wb + -- This INNER JOIN prevents a seq scan of the workspace_builds table. + -- Limit the rows to the absolute minimum required, which is all workspaces + -- in a given organization for a given user. +INNER JOIN + workspaces on wb.workspace_id = workspaces.id +WHERE + workspaces.owner_id = @owner_id AND + workspaces.organization_id = @organization_id ORDER BY - workspace_id, - created_at DESC + wb.workspace_id, + wb.created_at DESC ) SELECT coalesce(SUM(daily_cost), 0)::BIGINT FROM workspaces -JOIN latest_builds ON +INNER JOIN latest_builds ON latest_builds.workspace_id = workspaces.id -WHERE NOT - deleted AND +WHERE + NOT deleted AND + -- We can likely remove these conditions since we check above. + -- But it does not hurt to be defensive and make sure future query changes + -- do not break anything. workspaces.owner_id = @owner_id AND workspaces.organization_id = @organization_id ; diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 013e2b8027a45..a4f8844fd2db5 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -67,10 +67,15 @@ INSERT INTO created_at, updated_at, rbac_roles, - login_type + login_type, + status ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, + -- if the status passed in is empty, fallback to dormant, which is what + -- we were doing before. + COALESCE(NULLIF(@status::text, '')::user_status, 'dormant'::user_status) + ) RETURNING *; -- name: UpdateUserProfile :one UPDATE @@ -286,7 +291,7 @@ SET WHERE last_seen_at < @last_seen_after :: timestamp AND status = 'active'::user_status -RETURNING id, email, last_seen_at; +RETURNING id, email, username, last_seen_at; -- AllUserIDs returns all UserIDs regardless of user status or deletion. -- name: AllUserIDs :many diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 08e795d7a2402..369333a5eab9d 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -233,7 +233,7 @@ WHERE -- Filter by owner_name AND CASE WHEN @owner_username :: text != '' THEN - workspaces.owner_id = (SELECT id FROM users WHERE lower(owner_username) = lower(@owner_username) AND deleted = false) + workspaces.owner_id = (SELECT id FROM users WHERE lower(users.username) = lower(@owner_username) AND deleted = false) ELSE true END -- Filter by template_name diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index c4d1c7f202533..f6746b95eb20e 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -82,6 +82,7 @@ const ( type ExtractAPIKeyConfig struct { DB database.Store + ActivateDormantUser func(ctx context.Context, u database.User) (database.User, error) OAuth2Configs *OAuth2Configs RedirectToLogin bool DisableSessionExpiryRefresh bool @@ -414,21 +415,20 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } - if userStatus == database.UserStatusDormant { - // If coder confirms that the dormant user is valid, it can switch their account to active. - // nolint:gocritic - u, err := cfg.DB.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ - ID: key.UserID, - Status: database.UserStatusActive, - UpdatedAt: dbtime.Now(), + if userStatus == database.UserStatusDormant && cfg.ActivateDormantUser != nil { + id, _ := uuid.Parse(actor.ID) + user, err := cfg.ActivateDormantUser(ctx, database.User{ + ID: id, + Username: actor.FriendlyName, + Status: userStatus, }) if err != nil { return write(http.StatusInternalServerError, codersdk.Response{ Message: internalErrorMessage, - Detail: fmt.Sprintf("can't activate a dormant user: %s", err.Error()), + Detail: fmt.Sprintf("update user status: %s", err.Error()), }) } - userStatus = u.Status + userStatus = user.Status } if userStatus != database.UserStatusActive { diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index e18aeaef88b81..dfb628b62eb86 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -453,7 +453,7 @@ func (s *SMTPHandler) auth(ctx context.Context, mechs string) (sasl.Client, erro continue } if password == "" { - errs = multierror.Append(errs, xerrors.New("cannot use PLAIN auth, password not defined (see CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD)")) + errs = multierror.Append(errs, xerrors.New("cannot use PLAIN auth, password not defined (see CODER_EMAIL_AUTH_PASSWORD)")) continue } @@ -475,7 +475,7 @@ func (s *SMTPHandler) auth(ctx context.Context, mechs string) (sasl.Client, erro continue } if password == "" { - errs = multierror.Append(errs, xerrors.New("cannot use LOGIN auth, password not defined (see CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD)")) + errs = multierror.Append(errs, xerrors.New("cannot use LOGIN auth, password not defined (see CODER_EMAIL_AUTH_PASSWORD)")) continue } diff --git a/coderd/prometheusmetrics/prometheusmetrics.go b/coderd/prometheusmetrics/prometheusmetrics.go index ebd50ff0f42ce..ccd88a9e3fc1d 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" "tailscale.com/tailcfg" "cdr.dev/slog" @@ -22,12 +23,13 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/tailnet" + "github.com/coder/quartz" ) const defaultRefreshRate = time.Minute // ActiveUsers tracks the number of users that have authenticated within the past hour. -func ActiveUsers(ctx context.Context, registerer prometheus.Registerer, db database.Store, duration time.Duration) (func(), error) { +func ActiveUsers(ctx context.Context, logger slog.Logger, registerer prometheus.Registerer, db database.Store, duration time.Duration) (func(), error) { if duration == 0 { duration = defaultRefreshRate } @@ -58,6 +60,7 @@ func ActiveUsers(ctx context.Context, registerer prometheus.Registerer, db datab apiKeys, err := db.GetAPIKeysLastUsedAfter(ctx, dbtime.Now().Add(-1*time.Hour)) if err != nil { + logger.Error(ctx, "get api keys for active users prometheus metric", slog.Error(err)) continue } distinctUsers := map[uuid.UUID]struct{}{} @@ -73,6 +76,57 @@ func ActiveUsers(ctx context.Context, registerer prometheus.Registerer, db datab }, nil } +// Users tracks the total number of registered users, partitioned by status. +func Users(ctx context.Context, logger slog.Logger, clk quartz.Clock, registerer prometheus.Registerer, db database.Store, duration time.Duration) (func(), error) { + if duration == 0 { + // It's not super important this tracks real-time. + duration = defaultRefreshRate * 5 + } + + gauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "coderd", + Subsystem: "api", + Name: "total_user_count", + Help: "The total number of registered users, partitioned by status.", + }, []string{"status"}) + err := registerer.Register(gauge) + if err != nil { + return nil, xerrors.Errorf("register total_user_count gauge: %w", err) + } + + ctx, cancelFunc := context.WithCancel(ctx) + done := make(chan struct{}) + ticker := clk.NewTicker(duration) + go func() { + defer close(done) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + gauge.Reset() + //nolint:gocritic // This is a system service that needs full access + //to the users table. + users, err := db.GetUsers(dbauthz.AsSystemRestricted(ctx), database.GetUsersParams{}) + if err != nil { + logger.Error(ctx, "get all users for prometheus metrics", slog.Error(err)) + continue + } + + for _, user := range users { + gauge.WithLabelValues(string(user.Status)).Inc() + } + } + }() + return func() { + cancelFunc() + <-done + }, nil +} + // Workspaces tracks the total number of workspaces with labels on status. func Workspaces(ctx context.Context, logger slog.Logger, registerer prometheus.Registerer, db database.Store, duration time.Duration) (func(), error) { if duration == 0 { diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 1c904d9f342e2..84aeda148662e 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -38,6 +38,7 @@ import ( "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/tailnettest" "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestActiveUsers(t *testing.T) { @@ -98,7 +99,7 @@ func TestActiveUsers(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { t.Parallel() registry := prometheus.NewRegistry() - closeFunc, err := prometheusmetrics.ActiveUsers(context.Background(), registry, tc.Database(t), time.Millisecond) + closeFunc, err := prometheusmetrics.ActiveUsers(context.Background(), slogtest.Make(t, nil), registry, tc.Database(t), time.Millisecond) require.NoError(t, err) t.Cleanup(closeFunc) @@ -112,6 +113,100 @@ func TestActiveUsers(t *testing.T) { } } +func TestUsers(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + Name string + Database func(t *testing.T) database.Store + Count map[database.UserStatus]int + }{{ + Name: "None", + Database: func(t *testing.T) database.Store { + return dbmem.New() + }, + Count: map[database.UserStatus]int{}, + }, { + Name: "One", + Database: func(t *testing.T) database.Store { + db := dbmem.New() + dbgen.User(t, db, database.User{Status: database.UserStatusActive}) + return db + }, + Count: map[database.UserStatus]int{database.UserStatusActive: 1}, + }, { + Name: "MultipleStatuses", + Database: func(t *testing.T) database.Store { + db := dbmem.New() + + dbgen.User(t, db, database.User{Status: database.UserStatusActive}) + dbgen.User(t, db, database.User{Status: database.UserStatusDormant}) + + return db + }, + Count: map[database.UserStatus]int{database.UserStatusActive: 1, database.UserStatusDormant: 1}, + }, { + Name: "MultipleActive", + Database: func(t *testing.T) database.Store { + db := dbmem.New() + dbgen.User(t, db, database.User{Status: database.UserStatusActive}) + dbgen.User(t, db, database.User{Status: database.UserStatusActive}) + dbgen.User(t, db, database.User{Status: database.UserStatusActive}) + return db + }, + Count: map[database.UserStatus]int{database.UserStatusActive: 3}, + }} { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + registry := prometheus.NewRegistry() + mClock := quartz.NewMock(t) + db := tc.Database(t) + closeFunc, err := prometheusmetrics.Users(context.Background(), slogtest.Make(t, nil), mClock, registry, db, time.Millisecond) + require.NoError(t, err) + t.Cleanup(closeFunc) + + _, w := mClock.AdvanceNext() + w.MustWait(ctx) + + checkFn := func() bool { + metrics, err := registry.Gather() + if err != nil { + return false + } + + // If we get no metrics and we know none should exist, bail + // early. If we get no metrics but we expect some, retry. + if len(metrics) == 0 { + return len(tc.Count) == 0 + } + + for _, metric := range metrics[0].Metric { + if tc.Count[database.UserStatus(*metric.Label[0].Value)] != int(metric.Gauge.GetValue()) { + return false + } + } + + return true + } + + require.Eventually(t, checkFn, testutil.WaitShort, testutil.IntervalFast) + + // Add another dormant user and ensure it updates + dbgen.User(t, db, database.User{Status: database.UserStatusDormant}) + tc.Count[database.UserStatusDormant]++ + + _, w = mClock.AdvanceNext() + w.MustWait(ctx) + + require.Eventually(t, checkFn, testutil.WaitShort, testutil.IntervalFast) + }) + } +} + func TestWorkspaceLatestBuildTotals(t *testing.T) { t.Parallel() diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 0a4198423e403..e4628c8c8fd28 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1063,6 +1063,7 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. wriBytes, err := json.Marshal(buildResourceInfo) if err != nil { s.Logger.Error(ctx, "marshal workspace resource info for failed job", slog.Error(err)) + wriBytes = []byte("{}") } bag := audit.BaggageFromContext(ctx) diff --git a/coderd/userauth.go b/coderd/userauth.go index 13f9b088d731f..1dc399e1ac3b5 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -27,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/jwtutils" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" @@ -565,20 +567,13 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co return user, rbac.Subject{}, false } - if user.Status == database.UserStatusDormant { - //nolint:gocritic // System needs to update status of the user account (dormant -> active). - user, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ - ID: user.ID, - Status: database.UserStatusActive, - UpdatedAt: dbtime.Now(), + user, err = ActivateDormantUser(api.Logger, &api.Auditor, api.Database)(ctx, user) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error.", + Detail: err.Error(), }) - if err != nil { - logger.Error(ctx, "unable to update user status to active", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error occurred. Try again later, or contact an admin for assistance.", - }) - return user, rbac.Subject{}, false - } + return user, rbac.Subject{}, false } subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, user.ID, rbac.ScopeAll) @@ -601,6 +596,42 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co return user, subject, true } +func ActivateDormantUser(logger slog.Logger, auditor *atomic.Pointer[audit.Auditor], db database.Store) func(ctx context.Context, user database.User) (database.User, error) { + return func(ctx context.Context, user database.User) (database.User, error) { + if user.ID == uuid.Nil || user.Status != database.UserStatusDormant { + return user, nil + } + + //nolint:gocritic // System needs to update status of the user account (dormant -> active). + newUser, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ + ID: user.ID, + Status: database.UserStatusActive, + UpdatedAt: dbtime.Now(), + }) + if err != nil { + logger.Error(ctx, "unable to update user status to active", slog.Error(err)) + return user, xerrors.Errorf("update user status: %w", err) + } + + oldAuditUser := user + newAuditUser := user + newAuditUser.Status = database.UserStatusActive + + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.User]{ + Audit: *auditor.Load(), + Log: logger, + UserID: user.ID, + Action: database.AuditActionWrite, + Old: oldAuditUser, + New: newAuditUser, + Status: http.StatusOK, + AdditionalFields: audit.BackgroundTaskFieldsBytes(ctx, logger, audit.BackgroundSubsystemDormancy), + }) + + return newUser, nil + } +} + // Clear the user's session cookie. // // @Summary Log out user @@ -1385,10 +1416,22 @@ func (p *oauthLoginParams) CommitAuditLogs() { func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.Cookie, database.User, database.APIKey, error) { var ( - ctx = r.Context() - user database.User - cookies []*http.Cookie - logger = api.Logger.Named(userAuthLoggerName) + ctx = r.Context() + user database.User + cookies []*http.Cookie + logger = api.Logger.Named(userAuthLoggerName) + auditor = *api.Auditor.Load() + dormantConvertAudit *audit.Request[database.User] + initDormantAuditOnce = sync.OnceFunc(func() { + dormantConvertAudit = params.initAuditRequest(&audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + OrganizationID: uuid.Nil, + AdditionalFields: audit.BackgroundTaskFields(audit.BackgroundSubsystemDormancy), + }) + }) ) var isConvertLoginType bool @@ -1490,6 +1533,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C Email: params.Email, Username: params.Username, OrganizationIDs: orgIDs, + UserStatus: ptr.Ref(codersdk.UserStatusActive), }, LoginType: params.LoginType, accountCreatorName: "oauth", @@ -1501,6 +1545,11 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C // Activate dormant user on sign-in if user.Status == database.UserStatusDormant { + // This is necessary because transactions can be retried, and we + // only want to add the audit log a single time. + initDormantAuditOnce() + dormantConvertAudit.UserID = user.ID + dormantConvertAudit.Old = user //nolint:gocritic // System needs to update status of the user account (dormant -> active). user, err = tx.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ ID: user.ID, @@ -1511,6 +1560,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C logger.Error(ctx, "unable to update user status to active", slog.Error(err)) return xerrors.Errorf("update user status: %w", err) } + dormantConvertAudit.New = user } debugContext, err := json.Marshal(params.DebugContext) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 6386be7eb8be4..843f8ec753133 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1285,7 +1285,7 @@ func TestUserOIDC(t *testing.T) { tc.AssertResponse(t, resp) } - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, testutil.WaitShort) if tc.AssertUser != nil { user, err := client.User(ctx, "me") @@ -1300,6 +1300,49 @@ func TestUserOIDC(t *testing.T) { }) } + t.Run("OIDCDormancy", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + auditor := audit.NewMock() + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + Auditor: auditor, + OIDCConfig: cfg, + Logger: &logger, + }) + + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + Status: database.UserStatusDormant, + }) + auditor.ResetLogs() + + client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + auditor.Contains(t, database.AuditLog{ + ResourceType: database.ResourceTypeUser, + AdditionalFields: json.RawMessage(`{"automatic_actor":"coder","automatic_subsystem":"dormancy"}`), + }) + me, err := client.User(ctx, "me") + require.NoError(t, err) + + require.Equal(t, codersdk.UserStatusActive, me.Status) + }) + t.Run("OIDCConvert", func(t *testing.T) { t.Parallel() diff --git a/coderd/users.go b/coderd/users.go index 5e521da3a6004..445b44f334349 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" ) @@ -188,10 +189,13 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { //nolint:gocritic // needed to create first user user, err := api.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, CreateUserRequest{ CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{ - Email: createUser.Email, - Username: createUser.Username, - Name: createUser.Name, - Password: createUser.Password, + Email: createUser.Email, + Username: createUser.Username, + Name: createUser.Name, + Password: createUser.Password, + // There's no reason to create the first user as dormant, since you have + // to login immediately anyways. + UserStatus: ptr.Ref(codersdk.UserStatusActive), OrganizationIDs: []uuid.UUID{defaultOrg.ID}, }, LoginType: database.LoginTypePassword, @@ -1343,6 +1347,10 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create err := store.InTx(func(tx database.Store) error { orgRoles := make([]string, 0) + status := "" + if req.UserStatus != nil { + status = string(*req.UserStatus) + } params := database.InsertUserParams{ ID: uuid.New(), Email: req.Email, @@ -1354,6 +1362,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create // All new users are defaulted to members of the site. RBACRoles: []string{}, LoginType: req.LoginType, + Status: status, } // If a user signs up with OAuth, they can have no password! if req.Password != "" { diff --git a/coderd/users_test.go b/coderd/users_test.go index c33ca933a9d96..3c88d3e5022ac 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -30,6 +30,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -695,6 +696,41 @@ func TestPostUsers(t *testing.T) { }) require.NoError(t, err) + // User should default to dormant. + require.Equal(t, codersdk.UserStatusDormant, user.Status) + + require.Len(t, auditor.AuditLogs(), numLogs) + require.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[numLogs-1].Action) + require.Equal(t, database.AuditActionLogin, auditor.AuditLogs()[numLogs-2].Action) + + require.Len(t, user.OrganizationIDs, 1) + assert.Equal(t, firstUser.OrganizationID, user.OrganizationIDs[0]) + }) + + t.Run("CreateWithStatus", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + numLogs := len(auditor.AuditLogs()) + + firstUser := coderdtest.CreateFirstUser(t, client) + numLogs++ // add an audit log for user create + numLogs++ // add an audit log for login + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + UserStatus: ptr.Ref(codersdk.UserStatusActive), + }) + require.NoError(t, err) + + require.Equal(t, codersdk.UserStatusActive, user.Status) + require.Len(t, auditor.AuditLogs(), numLogs) require.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[numLogs-1].Action) require.Equal(t, database.AuditActionLogin, auditor.AuditLogs()[numLogs-2].Action) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index c24afc67de8ba..0a4e10670132c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1313,6 +1313,39 @@ func TestWorkspaceFilterManual(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, 0) }) + t.Run("Owner", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + otherUser, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner()) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + // Add a non-matching workspace + coderdtest.CreateWorkspace(t, otherUser, template.ID) + + workspaces := []codersdk.Workspace{ + coderdtest.CreateWorkspace(t, client, template.ID), + coderdtest.CreateWorkspace(t, client, template.ID), + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + sdkUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + // match owner name + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("owner:%s", sdkUser.Username), + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, len(workspaces)) + for _, found := range res.Workspaces { + require.Equal(t, found.OwnerName, sdkUser.Username) + } + }) t.Run("IDs", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 6a5f7c52ac8f5..3ba09bd38d1a4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -926,6 +926,23 @@ when required by your organization's security policy.`, Name: "Config", Description: `Use a YAML configuration file when your server launch become unwieldy.`, } + deploymentGroupEmail = serpent.Group{ + Name: "Email", + Description: "Configure how emails are sent.", + YAML: "email", + } + deploymentGroupEmailAuth = serpent.Group{ + Name: "Email Authentication", + Parent: &deploymentGroupEmail, + Description: "Configure SMTP authentication options.", + YAML: "emailAuth", + } + deploymentGroupEmailTLS = serpent.Group{ + Name: "Email TLS", + Parent: &deploymentGroupEmail, + Description: "Configure TLS for your SMTP server target.", + YAML: "emailTLS", + } deploymentGroupNotifications = serpent.Group{ Name: "Notifications", YAML: "notifications", @@ -997,6 +1014,135 @@ when required by your organization's security policy.`, Group: &deploymentGroupIntrospectionLogging, YAML: "filter", } + emailFrom := serpent.Option{ + Name: "Email: From Address", + Description: "The sender's address to use.", + Flag: "email-from", + Env: "CODER_EMAIL_FROM", + Value: &c.Notifications.SMTP.From, + Group: &deploymentGroupEmail, + YAML: "from", + } + emailSmarthost := serpent.Option{ + Name: "Email: Smarthost", + Description: "The intermediary SMTP host through which emails are sent.", + Flag: "email-smarthost", + Env: "CODER_EMAIL_SMARTHOST", + Default: "localhost:587", // To pass validation. + Value: &c.Notifications.SMTP.Smarthost, + Group: &deploymentGroupEmail, + YAML: "smarthost", + } + emailHello := serpent.Option{ + Name: "Email: Hello", + Description: "The hostname identifying the SMTP server.", + Flag: "email-hello", + Env: "CODER_EMAIL_HELLO", + Default: "localhost", + Value: &c.Notifications.SMTP.Hello, + Group: &deploymentGroupEmail, + YAML: "hello", + } + emailForceTLS := serpent.Option{ + Name: "Email: Force TLS", + Description: "Force a TLS connection to the configured SMTP smarthost.", + Flag: "email-force-tls", + Env: "CODER_EMAIL_FORCE_TLS", + Default: "false", + Value: &c.Notifications.SMTP.ForceTLS, + Group: &deploymentGroupEmail, + YAML: "forceTLS", + } + emailAuthIdentity := serpent.Option{ + Name: "Email Auth: Identity", + Description: "Identity to use with PLAIN authentication.", + Flag: "email-auth-identity", + Env: "CODER_EMAIL_AUTH_IDENTITY", + Value: &c.Notifications.SMTP.Auth.Identity, + Group: &deploymentGroupEmailAuth, + YAML: "identity", + } + emailAuthUsername := serpent.Option{ + Name: "Email Auth: Username", + Description: "Username to use with PLAIN/LOGIN authentication.", + Flag: "email-auth-username", + Env: "CODER_EMAIL_AUTH_USERNAME", + Value: &c.Notifications.SMTP.Auth.Username, + Group: &deploymentGroupEmailAuth, + YAML: "username", + } + emailAuthPassword := serpent.Option{ + Name: "Email Auth: Password", + Description: "Password to use with PLAIN/LOGIN authentication.", + Flag: "email-auth-password", + Env: "CODER_EMAIL_AUTH_PASSWORD", + Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), + Value: &c.Notifications.SMTP.Auth.Password, + Group: &deploymentGroupEmailAuth, + } + emailAuthPasswordFile := serpent.Option{ + Name: "Email Auth: Password File", + Description: "File from which to load password for use with PLAIN/LOGIN authentication.", + Flag: "email-auth-password-file", + Env: "CODER_EMAIL_AUTH_PASSWORD_FILE", + Value: &c.Notifications.SMTP.Auth.PasswordFile, + Group: &deploymentGroupEmailAuth, + YAML: "passwordFile", + } + emailTLSStartTLS := serpent.Option{ + Name: "Email TLS: StartTLS", + Description: "Enable STARTTLS to upgrade insecure SMTP connections using TLS.", + Flag: "email-tls-starttls", + Env: "CODER_EMAIL_TLS_STARTTLS", + Value: &c.Notifications.SMTP.TLS.StartTLS, + Group: &deploymentGroupEmailTLS, + YAML: "startTLS", + } + emailTLSServerName := serpent.Option{ + Name: "Email TLS: Server Name", + Description: "Server name to verify against the target certificate.", + Flag: "email-tls-server-name", + Env: "CODER_EMAIL_TLS_SERVERNAME", + Value: &c.Notifications.SMTP.TLS.ServerName, + Group: &deploymentGroupEmailTLS, + YAML: "serverName", + } + emailTLSSkipCertVerify := serpent.Option{ + Name: "Email TLS: Skip Certificate Verification (Insecure)", + Description: "Skip verification of the target server's certificate (insecure).", + Flag: "email-tls-skip-verify", + Env: "CODER_EMAIL_TLS_SKIPVERIFY", + Value: &c.Notifications.SMTP.TLS.InsecureSkipVerify, + Group: &deploymentGroupEmailTLS, + YAML: "insecureSkipVerify", + } + emailTLSCertAuthorityFile := serpent.Option{ + Name: "Email TLS: Certificate Authority File", + Description: "CA certificate file to use.", + Flag: "email-tls-ca-cert-file", + Env: "CODER_EMAIL_TLS_CACERTFILE", + Value: &c.Notifications.SMTP.TLS.CAFile, + Group: &deploymentGroupEmailTLS, + YAML: "caCertFile", + } + emailTLSCertFile := serpent.Option{ + Name: "Email TLS: Certificate File", + Description: "Certificate file to use.", + Flag: "email-tls-cert-file", + Env: "CODER_EMAIL_TLS_CERTFILE", + Value: &c.Notifications.SMTP.TLS.CertFile, + Group: &deploymentGroupEmailTLS, + YAML: "certFile", + } + emailTLSCertKeyFile := serpent.Option{ + Name: "Email TLS: Certificate Key File", + Description: "Certificate key file to use.", + Flag: "email-tls-cert-key-file", + Env: "CODER_EMAIL_TLS_CERTKEYFILE", + Value: &c.Notifications.SMTP.TLS.KeyFile, + Group: &deploymentGroupEmailTLS, + YAML: "certKeyFile", + } opts := serpent.OptionSet{ { Name: "Access URL", @@ -2432,6 +2578,21 @@ Write out the current server config as YAML to stdout.`, YAML: "thresholdDatabase", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, + // Email options + emailFrom, + emailSmarthost, + emailHello, + emailForceTLS, + emailAuthIdentity, + emailAuthUsername, + emailAuthPassword, + emailAuthPasswordFile, + emailTLSStartTLS, + emailTLSServerName, + emailTLSSkipCertVerify, + emailTLSCertAuthorityFile, + emailTLSCertFile, + emailTLSCertKeyFile, // Notifications Options { Name: "Notifications: Method", @@ -2462,36 +2623,37 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.From, Group: &deploymentGroupNotificationsEmail, YAML: "from", + UseInstead: serpent.OptionSet{emailFrom}, }, { Name: "Notifications: Email: Smarthost", Description: "The intermediary SMTP host through which emails are sent.", Flag: "notifications-email-smarthost", Env: "CODER_NOTIFICATIONS_EMAIL_SMARTHOST", - Default: "localhost:587", // To pass validation. Value: &c.Notifications.SMTP.Smarthost, Group: &deploymentGroupNotificationsEmail, YAML: "smarthost", + UseInstead: serpent.OptionSet{emailSmarthost}, }, { Name: "Notifications: Email: Hello", Description: "The hostname identifying the SMTP server.", Flag: "notifications-email-hello", Env: "CODER_NOTIFICATIONS_EMAIL_HELLO", - Default: "localhost", Value: &c.Notifications.SMTP.Hello, Group: &deploymentGroupNotificationsEmail, YAML: "hello", + UseInstead: serpent.OptionSet{emailHello}, }, { Name: "Notifications: Email: Force TLS", Description: "Force a TLS connection to the configured SMTP smarthost.", Flag: "notifications-email-force-tls", Env: "CODER_NOTIFICATIONS_EMAIL_FORCE_TLS", - Default: "false", Value: &c.Notifications.SMTP.ForceTLS, Group: &deploymentGroupNotificationsEmail, YAML: "forceTLS", + UseInstead: serpent.OptionSet{emailForceTLS}, }, { Name: "Notifications: Email Auth: Identity", @@ -2501,6 +2663,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.Auth.Identity, Group: &deploymentGroupNotificationsEmailAuth, YAML: "identity", + UseInstead: serpent.OptionSet{emailAuthIdentity}, }, { Name: "Notifications: Email Auth: Username", @@ -2510,6 +2673,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.Auth.Username, Group: &deploymentGroupNotificationsEmailAuth, YAML: "username", + UseInstead: serpent.OptionSet{emailAuthUsername}, }, { Name: "Notifications: Email Auth: Password", @@ -2519,6 +2683,7 @@ Write out the current server config as YAML to stdout.`, Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), Value: &c.Notifications.SMTP.Auth.Password, Group: &deploymentGroupNotificationsEmailAuth, + UseInstead: serpent.OptionSet{emailAuthPassword}, }, { Name: "Notifications: Email Auth: Password File", @@ -2528,6 +2693,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.Auth.PasswordFile, Group: &deploymentGroupNotificationsEmailAuth, YAML: "passwordFile", + UseInstead: serpent.OptionSet{emailAuthPasswordFile}, }, { Name: "Notifications: Email TLS: StartTLS", @@ -2537,6 +2703,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.TLS.StartTLS, Group: &deploymentGroupNotificationsEmailTLS, YAML: "startTLS", + UseInstead: serpent.OptionSet{emailTLSStartTLS}, }, { Name: "Notifications: Email TLS: Server Name", @@ -2546,6 +2713,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.TLS.ServerName, Group: &deploymentGroupNotificationsEmailTLS, YAML: "serverName", + UseInstead: serpent.OptionSet{emailTLSServerName}, }, { Name: "Notifications: Email TLS: Skip Certificate Verification (Insecure)", @@ -2555,6 +2723,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.TLS.InsecureSkipVerify, Group: &deploymentGroupNotificationsEmailTLS, YAML: "insecureSkipVerify", + UseInstead: serpent.OptionSet{emailTLSSkipCertVerify}, }, { Name: "Notifications: Email TLS: Certificate Authority File", @@ -2564,6 +2733,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.TLS.CAFile, Group: &deploymentGroupNotificationsEmailTLS, YAML: "caCertFile", + UseInstead: serpent.OptionSet{emailTLSCertAuthorityFile}, }, { Name: "Notifications: Email TLS: Certificate File", @@ -2573,6 +2743,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.TLS.CertFile, Group: &deploymentGroupNotificationsEmailTLS, YAML: "certFile", + UseInstead: serpent.OptionSet{emailTLSCertFile}, }, { Name: "Notifications: Email TLS: Certificate Key File", @@ -2582,6 +2753,7 @@ Write out the current server config as YAML to stdout.`, Value: &c.Notifications.SMTP.TLS.KeyFile, Group: &deploymentGroupNotificationsEmailTLS, YAML: "certKeyFile", + UseInstead: serpent.OptionSet{emailTLSCertKeyFile}, }, { Name: "Notifications: Webhook: Endpoint", diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index d7eca6323000c..61474a3b77ea1 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -78,6 +78,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { "Provisioner Daemon Pre-shared Key (PSK)": { yaml: true, }, + "Email Auth: Password": { + yaml: true, + }, "Notifications: Email Auth: Password": { yaml: true, }, diff --git a/codersdk/users.go b/codersdk/users.go index f57b8010f9229..546fcc99e9fbe 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -139,6 +139,8 @@ type CreateUserRequestWithOrgs struct { Password string `json:"password"` // UserLoginType defaults to LoginTypePassword. UserLoginType LoginType `json:"login_type"` + // UserStatus defaults to UserStatusDormant. + UserStatus *UserStatus `json:"user_status"` // OrganizationIDs is a list of organization IDs that the user should be a member of. OrganizationIDs []uuid.UUID `json:"organization_ids" validate:"" format:"uuid"` } diff --git a/docs/admin/monitoring/notifications/index.md b/docs/admin/monitoring/notifications/index.md index a98fa0b3e8b48..eabc09438d7b9 100644 --- a/docs/admin/monitoring/notifications/index.md +++ b/docs/admin/monitoring/notifications/index.md @@ -89,34 +89,34 @@ existing one. **Server Settings:** -| Required | CLI | Env | Type | Description | Default | -| :------: | --------------------------------- | ------------------------------------- | ----------- | ----------------------------------------- | ------------- | -| ✔️ | `--notifications-email-from` | `CODER_NOTIFICATIONS_EMAIL_FROM` | `string` | The sender's address to use. | | -| ✔️ | `--notifications-email-smarthost` | `CODER_NOTIFICATIONS_EMAIL_SMARTHOST` | `host:port` | The SMTP relay to send messages through. | localhost:587 | -| ✔️ | `--notifications-email-hello` | `CODER_NOTIFICATIONS_EMAIL_HELLO` | `string` | The hostname identifying the SMTP server. | localhost | +| Required | CLI | Env | Type | Description | Default | +| :------: | ------------------- | ----------------------- | ----------- | ----------------------------------------- | ------------- | +| ✔️ | `--email-from` | `CODER_EMAIL_FROM` | `string` | The sender's address to use. | | +| ✔️ | `--email-smarthost` | `CODER_EMAIL_SMARTHOST` | `host:port` | The SMTP relay to send messages through. | localhost:587 | +| ✔️ | `--email-hello` | `CODER_EMAIL_HELLO` | `string` | The hostname identifying the SMTP server. | localhost | **Authentication Settings:** -| Required | CLI | Env | Type | Description | -| :------: | ------------------------------------------ | ---------------------------------------------- | -------- | ------------------------------------------------------------------------- | -| - | `--notifications-email-auth-username` | `CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME` | `string` | Username to use with PLAIN/LOGIN authentication. | -| - | `--notifications-email-auth-password` | `CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD` | `string` | Password to use with PLAIN/LOGIN authentication. | -| - | `--notifications-email-auth-password-file` | `CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE` | `string` | File from which to load password for use with PLAIN/LOGIN authentication. | -| - | `--notifications-email-auth-identity` | `CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY` | `string` | Identity to use with PLAIN authentication. | +| Required | CLI | Env | Type | Description | +| :------: | ---------------------------- | -------------------------------- | -------- | ------------------------------------------------------------------------- | +| - | `--email-auth-username` | `CODER_EMAIL_AUTH_USERNAME` | `string` | Username to use with PLAIN/LOGIN authentication. | +| - | `--email-auth-password` | `CODER_EMAIL_AUTH_PASSWORD` | `string` | Password to use with PLAIN/LOGIN authentication. | +| - | `--email-auth-password-file` | `CODER_EMAIL_AUTH_PASSWORD_FILE` | `string` | File from which to load password for use with PLAIN/LOGIN authentication. | +| - | `--email-auth-identity` | `CODER_EMAIL_AUTH_IDENTITY` | `string` | Identity to use with PLAIN authentication. | **TLS Settings:** -| Required | CLI | Env | Type | Description | Default | -| :------: | ----------------------------------------- | ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| - | `--notifications-email-force-tls` | `CODER_NOTIFICATIONS_EMAIL_FORCE_TLS` | `bool` | Force a TLS connection to the configured SMTP smarthost. If port 465 is used, TLS will be forced. See https://datatracker.ietf.org/doc/html/rfc8314#section-3.3. | false | -| - | `--notifications-email-tls-starttls` | `CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS` | `bool` | Enable STARTTLS to upgrade insecure SMTP connections using TLS. Ignored if `CODER_NOTIFICATIONS_EMAIL_FORCE_TLS` is set. | false | -| - | `--notifications-email-tls-skip-verify` | `CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY` | `bool` | Skip verification of the target server's certificate (**insecure**). | false | -| - | `--notifications-email-tls-server-name` | `CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME` | `string` | Server name to verify against the target certificate. | | -| - | `--notifications-email-tls-cert-file` | `CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE` | `string` | Certificate file to use. | | -| - | `--notifications-email-tls-cert-key-file` | `CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE` | `string` | Certificate key file to use. | | +| Required | CLI | Env | Type | Description | Default | +| :------: | --------------------------- | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| - | `--email-force-tls` | `CODER_EMAIL_FORCE_TLS` | `bool` | Force a TLS connection to the configured SMTP smarthost. If port 465 is used, TLS will be forced. See https://datatracker.ietf.org/doc/html/rfc8314#section-3.3. | false | +| - | `--email-tls-starttls` | `CODER_EMAIL_TLS_STARTTLS` | `bool` | Enable STARTTLS to upgrade insecure SMTP connections using TLS. Ignored if `CODER_NOTIFICATIONS_EMAIL_FORCE_TLS` is set. | false | +| - | `--email-tls-skip-verify` | `CODER_EMAIL_TLS_SKIPVERIFY` | `bool` | Skip verification of the target server's certificate (**insecure**). | false | +| - | `--email-tls-server-name` | `CODER_EMAIL_TLS_SERVERNAME` | `string` | Server name to verify against the target certificate. | | +| - | `--email-tls-cert-file` | `CODER_EMAIL_TLS_CERTFILE` | `string` | Certificate file to use. | | +| - | `--email-tls-cert-key-file` | `CODER_EMAIL_TLS_CERTKEYFILE` | `string` | Certificate key file to use. | | -**NOTE:** you _MUST_ use `CODER_NOTIFICATIONS_EMAIL_FORCE_TLS` if your smarthost -supports TLS on a port other than `465`. +**NOTE:** you _MUST_ use `CODER_EMAIL_FORCE_TLS` if your smarthost supports TLS +on a port other than `465`. ### Send emails using G-Suite @@ -126,9 +126,9 @@ After setting the required fields above: account you wish to send from 2. Set the following configuration options: ``` - CODER_NOTIFICATIONS_EMAIL_SMARTHOST=smtp.gmail.com:465 - CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME=@ - CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD="" + CODER_EMAIL_SMARTHOST=smtp.gmail.com:465 + CODER_EMAIL_AUTH_USERNAME=@ + CODER_EMAIL_AUTH_PASSWORD="" ``` See @@ -142,10 +142,10 @@ After setting the required fields above: 1. Setup an account on Microsoft 365 or outlook.com 2. Set the following configuration options: ``` - CODER_NOTIFICATIONS_EMAIL_SMARTHOST=smtp-mail.outlook.com:587 - CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS=true - CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME=@ - CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD="" + CODER_EMAIL_SMARTHOST=smtp-mail.outlook.com:587 + CODER_EMAIL_TLS_STARTTLS=true + CODER_EMAIL_AUTH_USERNAME=@ + CODER_EMAIL_AUTH_PASSWORD="" ``` See diff --git a/docs/admin/templates/troubleshooting.md b/docs/admin/templates/troubleshooting.md index 7c61dfaa8be65..e08a422938e2f 100644 --- a/docs/admin/templates/troubleshooting.md +++ b/docs/admin/templates/troubleshooting.md @@ -154,3 +154,17 @@ the top of the script to exit on error. > **Note:** If you aren't seeing any logs, check that the `dir` directive points > to a valid directory in the file system. + +## Slow workspace startup times + +If your workspaces are taking longer to start than expected, or longer than +desired, you can diagnose which steps have the highest impact in the workspace +build timings UI (available in v2.17 and beyond). Admins can can +programmatically pull startup times for individual workspace builds using our +[build timings API endpoint](../../reference/api/builds.md#get-workspace-build-timings-by-id). + +See our +[guide on optimizing workspace build times](../../tutorials/best-practices/speed-up-templates.md) +to optimize your templates based on this data. + +![Workspace build timings UI](../../images/admin/templates/troubleshooting/workspace-build-timings-ui.png) diff --git a/docs/admin/users/groups-roles.md b/docs/admin/users/groups-roles.md index 77dd35bf9dd89..e40efb0bd5a10 100644 --- a/docs/admin/users/groups-roles.md +++ b/docs/admin/users/groups-roles.md @@ -31,6 +31,49 @@ Roles determine which actions users can take within the platform. A user may have one or more roles. All users have an implicit Member role that may use personal workspaces. +## Custom Roles (Premium) (Beta) + +Starting in v2.16.0, Premium Coder deployments can configure custom roles on the +[Organization](./organizations.md) level. You can create and assign custom roles +in the dashboard under **Organizations** -> **My Organization** -> **Roles**. + +> Note: This requires a Premium license. +> [Contact your account team](https://coder.com/contact) for more details. + +![Custom roles](../../images/admin/users/roles/custom-roles.PNG) + +### Example roles + +- The `Banking Compliance Auditor` custom role cannot create workspaces, but can + read template source code and view audit logs +- The `Organization Lead` role can access user workspaces for troubleshooting + purposes, but cannot edit templates +- The `Platform Member` role cannot edit or create workspaces as they are + created via a third-party system + +Custom roles can also be applied to +[headless user accounts](./headless-auth.md): + +- A `Health Check` role can view deployment status but cannot create workspaces, + manage templates, or view users +- A `CI` role can update manage templates but cannot create workspaces or view + users + +### Creating custom roles + +Clicking "Create custom role" opens a UI to select the desired permissions for a +given persona. + +![Creating a custom role](../../images/admin/users/roles/creating-custom-role.PNG) + +From there, you can assign the custom role to any user in the organization under +the **Users** settings in the dashboard. + +![Assigning a custom role](../../images/admin/users/roles/assigning-custom-role.PNG) + +Note that these permissions only apply to the scope of an +[organization](./organizations.md), not across the deployment. + ### Security notes A malicious Template Admin could write a template that executes commands on the diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index 6b500ea68ac66..a00030a514f05 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -143,7 +143,12 @@ Confirm the user activation by typing **yes** and pressing **enter**. ## Reset a password -To reset a user's via the web UI: +As of 2.17.0, users can reset their password independently on the login screen +by clicking "Forgot Password." This feature requires +[email notifications](../monitoring/notifications/index.md#smtp-email) to be +configured on the deployment. + +To reset a user's password as an administrator via the web UI: 1. Go to **Users**. 2. Find the user whose password you want to reset, click the vertical ellipsis diff --git a/docs/images/admin/templates/troubleshooting/workspace-build-timings-ui.png b/docs/images/admin/templates/troubleshooting/workspace-build-timings-ui.png new file mode 100644 index 0000000000000..137752ec1aa62 Binary files /dev/null and b/docs/images/admin/templates/troubleshooting/workspace-build-timings-ui.png differ diff --git a/docs/images/admin/users/roles/assigning-custom-role.PNG b/docs/images/admin/users/roles/assigning-custom-role.PNG new file mode 100644 index 0000000000000..271f1bcae7781 Binary files /dev/null and b/docs/images/admin/users/roles/assigning-custom-role.PNG differ diff --git a/docs/images/admin/users/roles/creating-custom-role.PNG b/docs/images/admin/users/roles/creating-custom-role.PNG new file mode 100644 index 0000000000000..a10725f9e0a71 Binary files /dev/null and b/docs/images/admin/users/roles/creating-custom-role.PNG differ diff --git a/docs/images/admin/users/roles/custom-roles.PNG b/docs/images/admin/users/roles/custom-roles.PNG new file mode 100644 index 0000000000000..14c50dba7d1e7 Binary files /dev/null and b/docs/images/admin/users/roles/custom-roles.PNG differ diff --git a/docs/images/best-practice/build-timeline.png b/docs/images/best-practice/build-timeline.png new file mode 100644 index 0000000000000..cb1c1191ee7cc Binary files /dev/null and b/docs/images/best-practice/build-timeline.png differ diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 600881ec0289f..94483b7c40b57 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -1,6 +1,6 @@ # Install Coder on Kubernetes -You can install Coder on Kubernetes using Helm. We run on most Kubernetes +You can install Coder on Kubernetes (K8s) using Helm. We run on most Kubernetes distributions, including [OpenShift](./openshift.md). ## Requirements @@ -121,27 +121,27 @@ coder: We support two release channels: mainline and stable - read the [Releases](./releases.md) page to learn more about which best suits your team. -For the **mainline** Coder release: +- **Mainline** Coder release: - + -```shell -helm install coder coder-v2/coder \ - --namespace coder \ - --values values.yaml \ - --version 2.15.0 -``` + ```shell + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml \ + --version 2.15.0 + ``` - For the **stable** Coder release: +- **Stable** Coder release: - + -```shell -helm install coder coder-v2/coder \ - --namespace coder \ - --values values.yaml \ - --version 2.15.1 -``` + ```shell + helm install coder coder-v2/coder \ + --namespace coder \ + --values values.yaml \ + --version 2.15.1 + ``` You can watch Coder start up by running `kubectl get pods -n coder`. Once Coder has started, the `coder-*` pods should enter the `Running` state. @@ -167,6 +167,18 @@ helm upgrade coder coder-v2/coder \ -f values.yaml ``` +## Coder Observability Chart + +Use the [Observability Helm chart](https://github.com/coder/observability) for a +pre-built set of dashboards to monitor your control plane over time. It includes +Grafana, Prometheus, Loki, and Alert Manager out-of-the-box, and can be deployed +on your existing Grafana instance. + +We recommend that all administrators deploying on Kubernetes set the +observability bundle up with the control plane from the start. For installation +instructions, visit the +[observability repository](https://github.com/coder/observability?tab=readme-ov-file#installation). + ## Kubernetes Security Reference Below are common requirements we see from our enterprise customers when diff --git a/docs/manifest.json b/docs/manifest.json index 05f4d5d3a7680..10f0cda33f1e9 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -723,6 +723,18 @@ "title": "FAQs", "description": "Miscellaneous FAQs from our community", "path": "./tutorials/faqs.md" + }, + { + "title": "Best practices", + "description": "Guides to help you make the most of your Coder experience", + "path": "./tutorials/best-practices/index.md", + "children": [ + { + "title": "Speed up your workspaces", + "description": "Speed up your Coder templates and workspaces", + "path": "./tutorials/best-practices/speed-up-templates.md" + } + ] } ] }, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f4e683305029b..64df97a031543 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1342,20 +1342,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "name": "string", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "password": "string", + "user_status": "active", "username": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ---------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------- | -| `email` | string | true | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | Organization ids is a list of organization IDs that the user should be a member of. | -| `password` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------- | +| `email` | string | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | Organization ids is a list of organization IDs that the user should be a member of. | +| `password` | string | false | | | +| `user_status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | User status defaults to UserStatusDormant. | +| `username` | string | true | | | ## codersdk.CreateWorkspaceBuildRequest diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 3979f5521b377..5e0ae3c239c04 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -86,6 +86,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "name": "string", "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "password": "string", + "user_status": "active", "username": "string" } ``` diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 981c2419cf903..42ef7f7418b45 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1249,6 +1249,148 @@ Refresh interval for healthchecks. The threshold for the database health check. If the median latency of the database exceeds this threshold over 5 attempts, the database is considered unhealthy. The default value is 15ms. +### --email-from + +| | | +| ----------- | ------------------------------ | +| Type | string | +| Environment | $CODER_EMAIL_FROM | +| YAML | email.from | + +The sender's address to use. + +### --email-smarthost + +| | | +| ----------- | ----------------------------------- | +| Type | host:port | +| Environment | $CODER_EMAIL_SMARTHOST | +| YAML | email.smarthost | +| Default | localhost:587 | + +The intermediary SMTP host through which emails are sent. + +### --email-hello + +| | | +| ----------- | ------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_HELLO | +| YAML | email.hello | +| Default | localhost | + +The hostname identifying the SMTP server. + +### --email-force-tls + +| | | +| ----------- | ----------------------------------- | +| Type | bool | +| Environment | $CODER_EMAIL_FORCE_TLS | +| YAML | email.forceTLS | +| Default | false | + +Force a TLS connection to the configured SMTP smarthost. + +### --email-auth-identity + +| | | +| ----------- | --------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_AUTH_IDENTITY | +| YAML | email.emailAuth.identity | + +Identity to use with PLAIN authentication. + +### --email-auth-username + +| | | +| ----------- | --------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_AUTH_USERNAME | +| YAML | email.emailAuth.username | + +Username to use with PLAIN/LOGIN authentication. + +### --email-auth-password + +| | | +| ----------- | --------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_AUTH_PASSWORD | + +Password to use with PLAIN/LOGIN authentication. + +### --email-auth-password-file + +| | | +| ----------- | -------------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_AUTH_PASSWORD_FILE | +| YAML | email.emailAuth.passwordFile | + +File from which to load password for use with PLAIN/LOGIN authentication. + +### --email-tls-starttls + +| | | +| ----------- | -------------------------------------- | +| Type | bool | +| Environment | $CODER_EMAIL_TLS_STARTTLS | +| YAML | email.emailTLS.startTLS | + +Enable STARTTLS to upgrade insecure SMTP connections using TLS. + +### --email-tls-server-name + +| | | +| ----------- | ---------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_TLS_SERVERNAME | +| YAML | email.emailTLS.serverName | + +Server name to verify against the target certificate. + +### --email-tls-skip-verify + +| | | +| ----------- | ---------------------------------------------- | +| Type | bool | +| Environment | $CODER_EMAIL_TLS_SKIPVERIFY | +| YAML | email.emailTLS.insecureSkipVerify | + +Skip verification of the target server's certificate (insecure). + +### --email-tls-ca-cert-file + +| | | +| ----------- | ---------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_TLS_CACERTFILE | +| YAML | email.emailTLS.caCertFile | + +CA certificate file to use. + +### --email-tls-cert-file + +| | | +| ----------- | -------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_TLS_CERTFILE | +| YAML | email.emailTLS.certFile | + +Certificate file to use. + +### --email-tls-cert-key-file + +| | | +| ----------- | ----------------------------------------- | +| Type | string | +| Environment | $CODER_EMAIL_TLS_CERTKEYFILE | +| YAML | email.emailTLS.certKeyFile | + +Certificate key file to use. + ### --notifications-method | | | @@ -1288,7 +1430,6 @@ The sender's address to use. | Type | host:port | | Environment | $CODER_NOTIFICATIONS_EMAIL_SMARTHOST | | YAML | notifications.email.smarthost | -| Default | localhost:587 | The intermediary SMTP host through which emails are sent. @@ -1299,7 +1440,6 @@ The intermediary SMTP host through which emails are sent. | Type | string | | Environment | $CODER_NOTIFICATIONS_EMAIL_HELLO | | YAML | notifications.email.hello | -| Default | localhost | The hostname identifying the SMTP server. @@ -1310,7 +1450,6 @@ The hostname identifying the SMTP server. | Type | bool | | Environment | $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS | | YAML | notifications.email.forceTLS | -| Default | false | Force a TLS connection to the configured SMTP smarthost. diff --git a/docs/tutorials/best-practices/index.md b/docs/tutorials/best-practices/index.md new file mode 100644 index 0000000000000..ccc12f61e5a92 --- /dev/null +++ b/docs/tutorials/best-practices/index.md @@ -0,0 +1,5 @@ +# Best practices + +Guides to help you make the most of your Coder experience. + + diff --git a/docs/tutorials/best-practices/speed-up-templates.md b/docs/tutorials/best-practices/speed-up-templates.md new file mode 100644 index 0000000000000..ddf08b5e51d75 --- /dev/null +++ b/docs/tutorials/best-practices/speed-up-templates.md @@ -0,0 +1,143 @@ +# Speed up your Coder templates and workspaces + +October 31, 2024 + +--- + +If it takes your workspace a long time to start, find out why and make some +changes to your Coder templates to help speed things up. + +## Monitoring + +You can monitor [Coder logs](../../admin/monitoring/logs.md) through the +system-native tools on your deployment platform, or stream logs to tools like +Splunk, Datadog, Grafana Loki, and others. + +### Workspace build timeline + +Use the **Build timeline** to monitor the time it takes to start specific +workspaces. Identify long scripts, resources, and other things you can +potentially optimize within the template. + +![Screenshot of a workspace and its build timeline](../../images/best-practice/build-timeline.png) + +Adjust this request to match your Coder access URL and workspace: + +```shell +curl -X GET https://coder.example.com/api/v2/workspacebuilds/{workspacebuild}/timings \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +Visit the +[API documentation](../../reference/api/builds.md#get-workspace-build-timings-by-id) +for more information. + +### Coder Observability Chart + +Use the [Observability Helm chart](https://github.com/coder/observability) for a +pre-built set of dashboards to monitor your control plane over time. It includes +Grafana, Prometheus, Loki, and Alert Manager out-of-the-box, and can be deployed +on your existing Grafana instance. + +We recommend that all administrators deploying on Kubernetes or on an existing +Prometheus or Grafana stack set the observability bundle up with the control +plane from the start. For installation instructions, visit the +[observability repository](https://github.com/coder/observability?tab=readme-ov-file#installation), +or our [Kubernetes installation guide](../../install/kubernetes.md). + +### Enable Prometheus metrics for Coder + +[Prometheus.io](https://prometheus.io/docs/introduction/overview/#what-is-prometheus) +is included as part of the [observability chart](#coder-observability-chart). It +offers a variety of +[available metrics](../../admin/integrations/prometheus.md#available-metrics), +such as `coderd_provisionerd_job_timings_seconds` and +`coderd_agentstats_startup_script_seconds`, which measure how long the workspace +takes to provision and how long the startup script takes. + +You can +[install it separately](https://prometheus.io/docs/prometheus/latest/getting_started/) +if you prefer. + +## Provisioners + +`coder server` defaults to three provisioner daemons. Each provisioner daemon +can handle one single job, such as start, stop, or delete at a time and can be +resource intensive. When all provisioners are busy, workspaces enter a "pending" +state until a provisioner becomes available. + +### Increase provisioner daemons + +Provisioners are queue-based to reduce unpredictable load to the Coder server. +However, they can be scaled up to allow more concurrent provisioners. You risk +overloading the central Coder server if you use too many built-in provisioners, +so we recommend a maximum of five provisioners. For more than five provisioners, +we recommend that you move to +[external provisioners](../../admin/provisioners.md). + +If you can’t move to external provisioners, use the `provisioner-daemons` flag +to increase the number of provisioner daemons to five: + +```shell +coder server --provisioner-daemons=5 +``` + +Visit the +[CLI documentation](../../reference/cli/server.md#--provisioner-daemons) for +more information about increasing provisioner daemons, configuring external +provisioners, and other options. + +### Adjust provisioner CPU/memory + +We recommend that you deploy Coder to its own respective Kubernetes cluster, +separate from production applications. Keep in mind that Coder runs development +workloads, so the cluster should be deployed as such, without production-level +configurations. + +Adjust the CPU and memory values as shown in +[Helm provisioner values.yaml](https://github.com/coder/coder/blob/main/helm/provisioner/values.yaml#L134-L141): + +```yaml +… + resources: + limits: + cpu: "0.25" + memory: "1Gi" + requests: + cpu: "0.25" + memory: "1Gi" +… +``` + +Visit the +[validated architecture documentation](../../admin/infrastructure/validated-architectures/index.md#workspace-nodes) +for more information. + +## Set up Terraform provider caching + +By default, Coder downloads each Terraform provider when a workspace starts. +This can create unnecessary network and disk I/O. + +`terraform init` generates a `.terraform.lock.hcl` which instructs Coder +provisioners to cache specific versions of your providers. + +To use `terraform init` to cache providers: + +1. Pull the templates to your local device: + + ```shell + coder templates pull + ``` + +1. Run `terraform init` to initialize the directory: + + ```shell + terraform init + ``` + +1. Push the templates back to your Coder deployment: + + ```shell + coder templates push + ``` diff --git a/docs/user-guides/workspace-lifecycle.md b/docs/user-guides/workspace-lifecycle.md index 12c2b021112dc..56d0c0b5ba7fd 100644 --- a/docs/user-guides/workspace-lifecycle.md +++ b/docs/user-guides/workspace-lifecycle.md @@ -109,6 +109,19 @@ your template's Terraform file and the target resources on your infrastructure. Unhealthy workspaces are usually caused by a misconfiguration in the agent or workspace startup scripts. +## Workspace build times + +After a successful build, you can see a timing breakdown of the workspace +startup process from the dashboard (starting in v2.17). We capture and display +both time taken to provision the workspace's compute and agent startup steps. +These include any +[`coder_script`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script)s +such as [dotfiles](./workspace-dotfiles.md) or +[`coder_app`](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app) +startups. + +![Workspace build timings UI](../images/admin/templates/troubleshooting/workspace-build-timings-ui.png) + ### Next steps - [Connecting to your workspace](./index.md) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 930a3e4956257..1bf4f31a8506b 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/enterprise/trialer" "github.com/coder/coder/v2/tailnet" + "github.com/coder/quartz" "github.com/coder/serpent" agplcoderd "github.com/coder/coder/v2/coderd" @@ -95,7 +96,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), ProvisionerDaemonPSK: options.DeploymentValues.Provisioner.DaemonPSK.Value(), - CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, options.Database), + CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, quartz.NewReal(), options.Database, options.Auditor), } if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 { diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index b637a0da3f74d..a6398586fa972 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -107,6 +107,58 @@ Use a YAML configuration file when your server launch become unwieldy. Write out the current server config as YAML to stdout. +EMAIL OPTIONS: +Configure how emails are sent. + + --email-force-tls bool, $CODER_EMAIL_FORCE_TLS (default: false) + Force a TLS connection to the configured SMTP smarthost. + + --email-from string, $CODER_EMAIL_FROM + The sender's address to use. + + --email-hello string, $CODER_EMAIL_HELLO (default: localhost) + The hostname identifying the SMTP server. + + --email-smarthost host:port, $CODER_EMAIL_SMARTHOST (default: localhost:587) + The intermediary SMTP host through which emails are sent. + +EMAIL / EMAIL AUTHENTICATION OPTIONS: +Configure SMTP authentication options. + + --email-auth-identity string, $CODER_EMAIL_AUTH_IDENTITY + Identity to use with PLAIN authentication. + + --email-auth-password string, $CODER_EMAIL_AUTH_PASSWORD + Password to use with PLAIN/LOGIN authentication. + + --email-auth-password-file string, $CODER_EMAIL_AUTH_PASSWORD_FILE + File from which to load password for use with PLAIN/LOGIN + authentication. + + --email-auth-username string, $CODER_EMAIL_AUTH_USERNAME + Username to use with PLAIN/LOGIN authentication. + +EMAIL / EMAIL TLS OPTIONS: +Configure TLS for your SMTP server target. + + --email-tls-ca-cert-file string, $CODER_EMAIL_TLS_CACERTFILE + CA certificate file to use. + + --email-tls-cert-file string, $CODER_EMAIL_TLS_CERTFILE + Certificate file to use. + + --email-tls-cert-key-file string, $CODER_EMAIL_TLS_CERTKEYFILE + Certificate key file to use. + + --email-tls-server-name string, $CODER_EMAIL_TLS_SERVERNAME + Server name to verify against the target certificate. + + --email-tls-skip-verify bool, $CODER_EMAIL_TLS_SKIPVERIFY + Skip verification of the target server's certificate (insecure). + + --email-tls-starttls bool, $CODER_EMAIL_TLS_STARTTLS + Enable STARTTLS to upgrade insecure SMTP connections using TLS. + INTROSPECTION / HEALTH CHECK OPTIONS: --health-check-refresh duration, $CODER_HEALTH_CHECK_REFRESH (default: 10m0s) Refresh interval for healthchecks. @@ -350,54 +402,68 @@ Configure how notifications are processed and delivered. NOTIFICATIONS / EMAIL OPTIONS: Configure how email notifications are sent. - --notifications-email-force-tls bool, $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS (default: false) + --notifications-email-force-tls bool, $CODER_NOTIFICATIONS_EMAIL_FORCE_TLS Force a TLS connection to the configured SMTP smarthost. + DEPRECATED: Use --email-force-tls instead. --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM The sender's address to use. + DEPRECATED: Use --email-from instead. - --notifications-email-hello string, $CODER_NOTIFICATIONS_EMAIL_HELLO (default: localhost) + --notifications-email-hello string, $CODER_NOTIFICATIONS_EMAIL_HELLO The hostname identifying the SMTP server. + DEPRECATED: Use --email-hello instead. - --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST (default: localhost:587) + --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST The intermediary SMTP host through which emails are sent. + DEPRECATED: Use --email-smarthost instead. NOTIFICATIONS / EMAIL / EMAIL AUTHENTICATION OPTIONS: Configure SMTP authentication options. --notifications-email-auth-identity string, $CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY Identity to use with PLAIN authentication. + DEPRECATED: Use --email-auth-identity instead. --notifications-email-auth-password string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD Password to use with PLAIN/LOGIN authentication. + DEPRECATED: Use --email-auth-password instead. --notifications-email-auth-password-file string, $CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE File from which to load password for use with PLAIN/LOGIN authentication. + DEPRECATED: Use --email-auth-password-file instead. --notifications-email-auth-username string, $CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME Username to use with PLAIN/LOGIN authentication. + DEPRECATED: Use --email-auth-username instead. NOTIFICATIONS / EMAIL / EMAIL TLS OPTIONS: Configure TLS for your SMTP server target. --notifications-email-tls-ca-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE CA certificate file to use. + DEPRECATED: Use --email-tls-ca-cert-file instead. --notifications-email-tls-cert-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE Certificate file to use. + DEPRECATED: Use --email-tls-cert-file instead. --notifications-email-tls-cert-key-file string, $CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE Certificate key file to use. + DEPRECATED: Use --email-tls-cert-key-file instead. --notifications-email-tls-server-name string, $CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME Server name to verify against the target certificate. + DEPRECATED: Use --email-tls-server-name instead. --notifications-email-tls-skip-verify bool, $CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY Skip verification of the target server's certificate (insecure). + DEPRECATED: Use --email-tls-skip-verify instead. --notifications-email-tls-starttls bool, $CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS Enable STARTTLS to upgrade insecure SMTP connections using TLS. + DEPRECATED: Use --email-tls-starttls instead. NOTIFICATIONS / WEBHOOK OPTIONS: --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 7e59eb341411f..dddf619b34058 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -172,6 +172,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { } apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ DB: options.Database, + ActivateDormantUser: coderd.ActivateDormantUser(options.Logger, &api.AGPL.Auditor, options.Database), OAuth2Configs: oauthConfigs, RedirectToLogin: false, DisableSessionExpiryRefresh: options.DeploymentValues.Sessions.DisableExpiryRefresh.Value(), diff --git a/enterprise/coderd/dormancy/dormantusersjob.go b/enterprise/coderd/dormancy/dormantusersjob.go index 8c8e22310c031..cae442ce07507 100644 --- a/enterprise/coderd/dormancy/dormantusersjob.go +++ b/enterprise/coderd/dormancy/dormantusersjob.go @@ -3,14 +3,17 @@ package dormancy import ( "context" "database/sql" + "net/http" "time" "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/quartz" ) const ( @@ -22,50 +25,49 @@ const ( // CheckInactiveUsers function updates status of inactive users from active to dormant // using default parameters. -func CheckInactiveUsers(ctx context.Context, logger slog.Logger, db database.Store) func() { - return CheckInactiveUsersWithOptions(ctx, logger, db, jobInterval, accountDormancyPeriod) +func CheckInactiveUsers(ctx context.Context, logger slog.Logger, clk quartz.Clock, db database.Store, auditor audit.Auditor) func() { + return CheckInactiveUsersWithOptions(ctx, logger, clk, db, auditor, jobInterval, accountDormancyPeriod) } // CheckInactiveUsersWithOptions function updates status of inactive users from active to dormant // using provided parameters. -func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db database.Store, checkInterval, dormancyPeriod time.Duration) func() { +func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, clk quartz.Clock, db database.Store, auditor audit.Auditor, checkInterval, dormancyPeriod time.Duration) func() { logger = logger.Named("dormancy") ctx, cancelFunc := context.WithCancel(ctx) - done := make(chan struct{}) - ticker := time.NewTicker(checkInterval) - go func() { - defer close(done) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - } + tf := clk.TickerFunc(ctx, checkInterval, func() error { + startTime := time.Now() + lastSeenAfter := dbtime.Now().Add(-dormancyPeriod) + logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) - startTime := time.Now() - lastSeenAfter := dbtime.Now().Add(-dormancyPeriod) - logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) + updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{ + LastSeenAfter: lastSeenAfter, + UpdatedAt: dbtime.Now(), + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) + return nil + } - updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{ - LastSeenAfter: lastSeenAfter, - UpdatedAt: dbtime.Now(), + for _, u := range updatedUsers { + logger.Info(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt)) + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.User]{ + Audit: auditor, + Log: logger, + UserID: u.ID, + Action: database.AuditActionWrite, + Old: database.User{ID: u.ID, Username: u.Username, Status: database.UserStatusActive}, + New: database.User{ID: u.ID, Username: u.Username, Status: database.UserStatusDormant}, + Status: http.StatusOK, + AdditionalFields: audit.BackgroundTaskFieldsBytes(ctx, logger, audit.BackgroundSubsystemDormancy), }) - if err != nil && !xerrors.Is(err, sql.ErrNoRows) { - logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) - continue - } - - for _, u := range updatedUsers { - logger.Info(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt)) - } - logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers)), slog.F("execution_time", time.Since(startTime))) } - }() + logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers)), slog.F("execution_time", time.Since(startTime))) + return nil + }) return func() { cancelFunc() - <-done + _ = tf.Wait() } } diff --git a/enterprise/coderd/dormancy/dormantusersjob_test.go b/enterprise/coderd/dormancy/dormantusersjob_test.go index c752e84bc1d90..bb3e0b4170baf 100644 --- a/enterprise/coderd/dormancy/dormantusersjob_test.go +++ b/enterprise/coderd/dormancy/dormantusersjob_test.go @@ -10,10 +10,11 @@ import ( "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/enterprise/coderd/dormancy" - "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestCheckInactiveUsers(t *testing.T) { @@ -42,29 +43,34 @@ func TestCheckInactiveUsers(t *testing.T) { suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + mAudit := audit.NewMock() + mClock := quartz.NewMock(t) // Run the periodic job - closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, db, interval, dormancyPeriod) + closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, mClock, db, mAudit, interval, dormancyPeriod) t.Cleanup(closeFunc) - var rows []database.GetUsersRow - var err error - require.Eventually(t, func() bool { - rows, err = db.GetUsers(ctx, database.GetUsersParams{}) - if err != nil { - return false - } + dur, w := mClock.AdvanceNext() + require.Equal(t, interval, dur) + w.MustWait(ctx) + + rows, err := db.GetUsers(ctx, database.GetUsersParams{}) + require.NoError(t, err) - var dormant, suspended int - for _, row := range rows { - if row.Status == database.UserStatusDormant { - dormant++ - } else if row.Status == database.UserStatusSuspended { - suspended++ - } + var dormant, suspended int + for _, row := range rows { + if row.Status == database.UserStatusDormant { + dormant++ + } else if row.Status == database.UserStatusSuspended { + suspended++ } - // 6 users in total, 3 dormant, 3 suspended - return len(rows) == 9 && dormant == 3 && suspended == 3 - }, testutil.WaitShort, testutil.IntervalMedium) + } + + // 9 users in total, 3 active, 3 dormant, 3 suspended + require.Len(t, rows, 9) + require.Equal(t, 3, dormant) + require.Equal(t, 3, suspended) + + require.Len(t, mAudit.AuditLogs(), 3) allUsers := ignoreUpdatedAt(database.ConvertUserRows(rows)) diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index ac4a77eaec8b4..13142f11e5717 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -2,11 +2,13 @@ package coderd_test import ( "context" + "database/sql" "encoding/json" "fmt" "net/http" "sync" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -14,6 +16,11 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -295,6 +302,497 @@ func TestWorkspaceQuota(t *testing.T) { }) } +// nolint:paralleltest,tparallel // Tests must run serially +func TestWorkspaceSerialization(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("Serialization errors only occur in postgres") + } + + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + otherUser := dbgen.User(t, db, database.User{}) + + org := dbfake.Organization(t, db). + EveryoneAllowance(20). + Members(user, otherUser). + Group(database.Group{ + QuotaAllowance: 10, + }, user, otherUser). + Group(database.Group{ + QuotaAllowance: 10, + }, user). + Do() + + otherOrg := dbfake.Organization(t, db). + EveryoneAllowance(20). + Members(user, otherUser). + Group(database.Group{ + QuotaAllowance: 10, + }, user, otherUser). + Group(database.Group{ + QuotaAllowance: 10, + }, user). + Do() + + // TX mixing tests. **DO NOT** run these in parallel. + // The goal here is to mess around with different ordering of + // transactions and queries. + + // UpdateBuildDeadline bumps a workspace deadline while doing a quota + // commit to the same workspace build. + // + // Note: This passes if the interrupt is run before 'GetQuota()' + // Passing orders: + // - BeginTX -> Bump! -> GetQuota -> GetAllowance -> UpdateCost -> EndTx + // - BeginTX -> GetQuota -> GetAllowance -> UpdateCost -> Bump! -> EndTx + t.Run("UpdateBuildDeadline", func(t *testing.T) { + t.Log("Expected to fail. As long as quota & deadline are on the same " + + " table and affect the same row, this will likely always fail.") + + // +------------------------------+------------------+ + // | Begin Tx | | + // +------------------------------+------------------+ + // | GetQuota(user) | | + // +------------------------------+------------------+ + // | | BumpDeadline(w1) | + // +------------------------------+------------------+ + // | GetAllowance(user) | | + // +------------------------------+------------------+ + // | UpdateWorkspaceBuildCost(w1) | | + // +------------------------------+------------------+ + // | CommitTx() | | + // +------------------------------+------------------+ + // pq: could not serialize access due to concurrent update + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + bumpDeadline := func() { + err := db.InTx(func(db database.Store) error { + err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + Deadline: dbtime.Now(), + MaxDeadline: dbtime.Now(), + UpdatedAt: dbtime.Now(), + ID: myWorkspace.Build.ID, + }) + return err + }, &database.TxOptions{ + Isolation: sql.LevelSerializable, + }) + assert.NoError(t, err) + } + + // Start TX + // Run order + + quota := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + quota.GetQuota(ctx, t) // Step 1 + bumpDeadline() // Interrupt + quota.GetAllowance(ctx, t) // Step 2 + + err := quota.DBTx.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{ + ID: myWorkspace.Build.ID, + DailyCost: 10, + }) // Step 3 + require.ErrorContains(t, err, "could not serialize access due to concurrent update") + // End commit + require.ErrorContains(t, quota.Done(), "failed transaction") + }) + + // UpdateOtherBuildDeadline bumps a user's other workspace deadline + // while doing a quota commit. + t.Run("UpdateOtherBuildDeadline", func(t *testing.T) { + // +------------------------------+------------------+ + // | Begin Tx | | + // +------------------------------+------------------+ + // | GetQuota(user) | | + // +------------------------------+------------------+ + // | | BumpDeadline(w2) | + // +------------------------------+------------------+ + // | GetAllowance(user) | | + // +------------------------------+------------------+ + // | UpdateWorkspaceBuildCost(w1) | | + // +------------------------------+------------------+ + // | CommitTx() | | + // +------------------------------+------------------+ + // Works! + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + // Use the same template + otherWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }). + Seed(database.WorkspaceBuild{ + TemplateVersionID: myWorkspace.TemplateVersion.ID, + }). + Do() + + bumpDeadline := func() { + err := db.InTx(func(db database.Store) error { + err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + Deadline: dbtime.Now(), + MaxDeadline: dbtime.Now(), + UpdatedAt: dbtime.Now(), + ID: otherWorkspace.Build.ID, + }) + return err + }, &database.TxOptions{ + Isolation: sql.LevelSerializable, + }) + assert.NoError(t, err) + } + + // Start TX + // Run order + + quota := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + quota.GetQuota(ctx, t) // Step 1 + bumpDeadline() // Interrupt + quota.GetAllowance(ctx, t) // Step 2 + quota.UpdateWorkspaceBuildCostByID(ctx, t, 10) // Step 3 + // End commit + require.NoError(t, quota.Done()) + }) + + t.Run("ActivityBump", func(t *testing.T) { + t.Log("Expected to fail. As long as quota & deadline are on the same " + + " table and affect the same row, this will likely always fail.") + // +---------------------+----------------------------------+ + // | W1 Quota Tx | | + // +---------------------+----------------------------------+ + // | Begin Tx | | + // +---------------------+----------------------------------+ + // | GetQuota(w1) | | + // +---------------------+----------------------------------+ + // | GetAllowance(w1) | | + // +---------------------+----------------------------------+ + // | | ActivityBump(w1) | + // +---------------------+----------------------------------+ + // | UpdateBuildCost(w1) | | + // +---------------------+----------------------------------+ + // | CommitTx() | | + // +---------------------+----------------------------------+ + // pq: could not serialize access due to concurrent update + ctx := testutil.Context(t, testutil.WaitShort) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }). + Seed(database.WorkspaceBuild{ + // Make sure the bump does something + Deadline: dbtime.Now().Add(time.Hour * -20), + }). + Do() + + one := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + + // Run order + one.GetQuota(ctx, t) + one.GetAllowance(ctx, t) + + err := db.ActivityBumpWorkspace(ctx, database.ActivityBumpWorkspaceParams{ + NextAutostart: time.Now(), + WorkspaceID: myWorkspace.Workspace.ID, + }) + + assert.NoError(t, err) + + err = one.DBTx.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{ + ID: myWorkspace.Build.ID, + DailyCost: 10, + }) + require.ErrorContains(t, err, "could not serialize access due to concurrent update") + + // End commit + assert.ErrorContains(t, one.Done(), "failed transaction") + }) + + t.Run("BumpLastUsedAt", func(t *testing.T) { + // +---------------------+----------------------------------+ + // | W1 Quota Tx | | + // +---------------------+----------------------------------+ + // | Begin Tx | | + // +---------------------+----------------------------------+ + // | GetQuota(w1) | | + // +---------------------+----------------------------------+ + // | GetAllowance(w1) | | + // +---------------------+----------------------------------+ + // | | UpdateWorkspaceLastUsedAt(w1) | + // +---------------------+----------------------------------+ + // | UpdateBuildCost(w1) | | + // +---------------------+----------------------------------+ + // | CommitTx() | | + // +---------------------+----------------------------------+ + ctx := testutil.Context(t, testutil.WaitShort) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + one := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + + // Run order + one.GetQuota(ctx, t) + one.GetAllowance(ctx, t) + + err := db.UpdateWorkspaceLastUsedAt(ctx, database.UpdateWorkspaceLastUsedAtParams{ + ID: myWorkspace.Workspace.ID, + LastUsedAt: dbtime.Now(), + }) + assert.NoError(t, err) + + one.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + // End commit + assert.NoError(t, one.Done()) + }) + + t.Run("UserMod", func(t *testing.T) { + // +---------------------+----------------------------------+ + // | W1 Quota Tx | | + // +---------------------+----------------------------------+ + // | Begin Tx | | + // +---------------------+----------------------------------+ + // | GetQuota(w1) | | + // +---------------------+----------------------------------+ + // | GetAllowance(w1) | | + // +---------------------+----------------------------------+ + // | | RemoveUserFromOrg | + // +---------------------+----------------------------------+ + // | UpdateBuildCost(w1) | | + // +---------------------+----------------------------------+ + // | CommitTx() | | + // +---------------------+----------------------------------+ + // Works! + ctx := testutil.Context(t, testutil.WaitShort) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + var err error + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + one := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + + // Run order + + one.GetQuota(ctx, t) + one.GetAllowance(ctx, t) + + err = db.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{ + OrganizationID: myWorkspace.Workspace.OrganizationID, + UserID: user.ID, + }) + assert.NoError(t, err) + + one.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + // End commit + assert.NoError(t, one.Done()) + }) + + // QuotaCommit 2 workspaces in different orgs. + // Workspaces do not share templates, owners, or orgs + t.Run("DoubleQuotaUnrelatedWorkspaces", func(t *testing.T) { + // +---------------------+---------------------+ + // | W1 Quota Tx | W2 Quota Tx | + // +---------------------+---------------------+ + // | Begin Tx | | + // +---------------------+---------------------+ + // | | Begin Tx | + // +---------------------+---------------------+ + // | GetQuota(w1) | | + // +---------------------+---------------------+ + // | GetAllowance(w1) | | + // +---------------------+---------------------+ + // | UpdateBuildCost(w1) | | + // +---------------------+---------------------+ + // | | UpdateBuildCost(w2) | + // +---------------------+---------------------+ + // | | GetQuota(w2) | + // +---------------------+---------------------+ + // | | GetAllowance(w2) | + // +---------------------+---------------------+ + // | CommitTx() | | + // +---------------------+---------------------+ + // | | CommitTx() | + // +---------------------+---------------------+ + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + myOtherWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: otherOrg.Org.ID, // Different org! + OwnerID: otherUser.ID, + }).Do() + + one := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + two := newCommitter(t, db, myOtherWorkspace.Workspace, myOtherWorkspace.Build) + + // Run order + one.GetQuota(ctx, t) + one.GetAllowance(ctx, t) + + one.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + two.GetQuota(ctx, t) + two.GetAllowance(ctx, t) + two.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + // End commit + assert.NoError(t, one.Done()) + assert.NoError(t, two.Done()) + }) + + // QuotaCommit 2 workspaces in different orgs. + // Workspaces do not share templates or orgs + t.Run("DoubleQuotaUserWorkspacesDiffOrgs", func(t *testing.T) { + // +---------------------+---------------------+ + // | W1 Quota Tx | W2 Quota Tx | + // +---------------------+---------------------+ + // | Begin Tx | | + // +---------------------+---------------------+ + // | | Begin Tx | + // +---------------------+---------------------+ + // | GetQuota(w1) | | + // +---------------------+---------------------+ + // | GetAllowance(w1) | | + // +---------------------+---------------------+ + // | UpdateBuildCost(w1) | | + // +---------------------+---------------------+ + // | | UpdateBuildCost(w2) | + // +---------------------+---------------------+ + // | | GetQuota(w2) | + // +---------------------+---------------------+ + // | | GetAllowance(w2) | + // +---------------------+---------------------+ + // | CommitTx() | | + // +---------------------+---------------------+ + // | | CommitTx() | + // +---------------------+---------------------+ + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + myOtherWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: otherOrg.Org.ID, // Different org! + OwnerID: user.ID, + }).Do() + + one := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + two := newCommitter(t, db, myOtherWorkspace.Workspace, myOtherWorkspace.Build) + + // Run order + one.GetQuota(ctx, t) + one.GetAllowance(ctx, t) + + one.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + two.GetQuota(ctx, t) + two.GetAllowance(ctx, t) + two.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + // End commit + assert.NoError(t, one.Done()) + assert.NoError(t, two.Done()) + }) + + // QuotaCommit 2 workspaces in the same org. + // Workspaces do not share templates + t.Run("DoubleQuotaUserWorkspaces", func(t *testing.T) { + t.Log("Setting a new build cost to a workspace in a org affects other " + + "workspaces in the same org. This is expected to fail.") + // +---------------------+---------------------+ + // | W1 Quota Tx | W2 Quota Tx | + // +---------------------+---------------------+ + // | Begin Tx | | + // +---------------------+---------------------+ + // | | Begin Tx | + // +---------------------+---------------------+ + // | GetQuota(w1) | | + // +---------------------+---------------------+ + // | GetAllowance(w1) | | + // +---------------------+---------------------+ + // | UpdateBuildCost(w1) | | + // +---------------------+---------------------+ + // | | UpdateBuildCost(w2) | + // +---------------------+---------------------+ + // | | GetQuota(w2) | + // +---------------------+---------------------+ + // | | GetAllowance(w2) | + // +---------------------+---------------------+ + // | CommitTx() | | + // +---------------------+---------------------+ + // | | CommitTx() | + // +---------------------+---------------------+ + // pq: could not serialize access due to read/write dependencies among transactions + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // testing + ctx = dbauthz.AsSystemRestricted(ctx) + + myWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + myOtherWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: org.Org.ID, + OwnerID: user.ID, + }).Do() + + one := newCommitter(t, db, myWorkspace.Workspace, myWorkspace.Build) + two := newCommitter(t, db, myOtherWorkspace.Workspace, myOtherWorkspace.Build) + + // Run order + one.GetQuota(ctx, t) + one.GetAllowance(ctx, t) + + one.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + two.GetQuota(ctx, t) + two.GetAllowance(ctx, t) + two.UpdateWorkspaceBuildCostByID(ctx, t, 10) + + // End commit + assert.NoError(t, one.Done()) + assert.ErrorContains(t, two.Done(), "could not serialize access due to read/write dependencies among transactions") + }) +} + func deprecatedQuotaEndpoint(ctx context.Context, client *codersdk.Client, userID string) (codersdk.WorkspaceQuota, error) { res, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil) if err != nil { @@ -335,3 +833,65 @@ func applyWithCost(cost int32) []*proto.Response { }, }} } + +// committer does what the CommitQuota does, but allows +// stepping through the actions in the tx and controlling the +// timing. +// This is a nice wrapper to make the tests more concise. +type committer struct { + DBTx *dbtestutil.DBTx + w database.WorkspaceTable + b database.WorkspaceBuild +} + +func newCommitter(t *testing.T, db database.Store, workspace database.WorkspaceTable, build database.WorkspaceBuild) *committer { + quotaTX := dbtestutil.StartTx(t, db, &database.TxOptions{ + Isolation: sql.LevelSerializable, + ReadOnly: false, + }) + return &committer{DBTx: quotaTX, w: workspace, b: build} +} + +// GetQuota touches: +// - workspace_builds +// - workspaces +func (c *committer) GetQuota(ctx context.Context, t *testing.T) int64 { + t.Helper() + + consumed, err := c.DBTx.GetQuotaConsumedForUser(ctx, database.GetQuotaConsumedForUserParams{ + OwnerID: c.w.OwnerID, + OrganizationID: c.w.OrganizationID, + }) + require.NoError(t, err) + return consumed +} + +// GetAllowance touches: +// - group_members_expanded +// - users +// - groups +// - org_members +func (c *committer) GetAllowance(ctx context.Context, t *testing.T) int64 { + t.Helper() + + allowance, err := c.DBTx.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{ + UserID: c.w.OwnerID, + OrganizationID: c.w.OrganizationID, + }) + require.NoError(t, err) + return allowance +} + +func (c *committer) UpdateWorkspaceBuildCostByID(ctx context.Context, t *testing.T, cost int32) bool { + t.Helper() + + err := c.DBTx.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{ + ID: c.b.ID, + DailyCost: cost, + }) + return assert.NoError(t, err) +} + +func (c *committer) Done() error { + return c.DBTx.Done() +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d687fb68ec61f..4568948595e7c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -328,6 +328,7 @@ export interface CreateUserRequestWithOrgs { readonly name: string; readonly password: string; readonly login_type: LoginType; + readonly user_status?: UserStatus; readonly organization_ids: Readonly>; } diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx index d8735d3f5cf71..4f28d7243a0bf 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.stories.tsx @@ -22,9 +22,3 @@ export default meta; type Story = StoryObj; export const Example: Story = {}; - -export const UserLimit: Story = { - args: { - userLimit: 10, - }, -}; diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.tsx index f1695b0641cc5..41345ea8f03f8 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.tsx @@ -14,7 +14,6 @@ import { Tooltip, defaults, } from "chart.js"; -import annotationPlugin from "chartjs-plugin-annotation"; import { HelpTooltip, HelpTooltipContent, @@ -36,21 +35,16 @@ ChartJS.register( Title, Tooltip, Legend, - annotationPlugin, ); -const USER_LIMIT_DISPLAY_THRESHOLD = 60; - export interface ActiveUserChartProps { data: readonly { date: string; amount: number }[]; interval: "day" | "week"; - userLimit: number | undefined; } export const ActiveUserChart: FC = ({ data, interval, - userLimit, }) => { const theme = useTheme(); @@ -64,24 +58,6 @@ export const ActiveUserChart: FC = ({ responsive: true, animation: false, plugins: { - annotation: { - annotations: [ - { - type: "line", - scaleID: "y", - display: shouldDisplayUserLimit(userLimit, chartData), - value: userLimit, - borderColor: theme.palette.secondary.contrastText, - borderWidth: 5, - label: { - content: "User limit", - color: theme.palette.primary.contrastText, - display: true, - font: { weight: "normal" }, - }, - }, - ], - }, legend: { display: false, }, @@ -103,7 +79,6 @@ export const ActiveUserChart: FC = ({ precision: 0, }, }, - x: { grid: { color: theme.palette.divider }, ticks: { @@ -138,32 +113,26 @@ export const ActiveUserChart: FC = ({ ); }; -export const ActiveUsersTitle: FC = () => { +type ActiveUsersTitleProps = { + interval: "day" | "week"; +}; + +export const ActiveUsersTitle: FC = ({ interval }) => { return (
- Active Users + {interval === "day" ? "Daily" : "Weekly"} Active Users How do we calculate active users? When a connection is initiated to a user's workspace they are - considered an active user. e.g. apps, web terminal, SSH + considered an active user. e.g. apps, web terminal, SSH. This is for + measuring user activity and has no connection to license + consumption.
); }; - -function shouldDisplayUserLimit( - userLimit: number | undefined, - activeUsers: number[], -): boolean { - if (!userLimit || activeUsers.length === 0) { - return false; - } - return ( - Math.max(...activeUsers) >= (userLimit * USER_LIMIT_DISPLAY_THRESHOLD) / 100 - ); -} diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx index dd00129f935eb..51d4e8ec910d9 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.tsx @@ -23,7 +23,7 @@ export const AuditLogDescription: FC = ({ target = ""; } - // This occurs when SCIM creates a user. + // This occurs when SCIM creates a user, or dormancy changes a users status. if ( auditLog.resource_type === "user" && auditLog.additional_fields?.automatic_actor === "coder" diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx index 6f09110e77a5e..9147a1a5befff 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx @@ -50,13 +50,6 @@ type Story = StoryObj; export const Page: Story = {}; -export const WithUserLimit: Story = { - args: { - deploymentDAUs: MockDeploymentDAUResponse, - entitlements: MockEntitlementsWithUserLimit, - }, -}; - export const NoDAUs: Story = { args: { deploymentDAUs: undefined, diff --git a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index 0b4ee0c6d0c43..29edacd08d9e7 100644 --- a/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -49,16 +49,8 @@ export const GeneralSettingsPageView: FC = ({ )} {deploymentDAUs && (
- }> - + }> +
)} diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx index 7fe492a1a3275..5ab6c0ea259f4 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx @@ -868,11 +868,3 @@ export const Loaded: Story = { }, }, }; - -export const LoadedWithUserLimit: Story = { - ...Loaded, - args: { - ...Loaded.args, - entitlements: MockEntitlementsWithUserLimit, - }, -}; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index a7e0351e8ba80..f205194a1aded 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -249,7 +249,7 @@ const ActiveUsersPanel: FC = ({ - + @@ -258,7 +258,6 @@ const ActiveUsersPanel: FC = ({ {data && data.length > 0 && ( ({ amount: d.active_users, date: d.start_time,