Compare commits

..

1 Commits

Author SHA1 Message Date
Bruno Quaresma 2c50cb7d35 chore: replace MUI Link - Phase 1 2025-10-21 18:11:05 +00:00
82 changed files with 898 additions and 2681 deletions
+1 -11
View File
@@ -169,16 +169,6 @@ linters-settings:
- name: var-declaration
- name: var-naming
- name: waitgroup-by-value
usetesting:
# Only os-setenv is enabled because we migrated to usetesting from another linter that
# only covered os-setenv.
os-setenv: true
os-create-temp: false
os-mkdir-temp: false
os-temp-dir: false
os-chdir: false
context-background: false
context-todo: false
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
govet:
@@ -262,6 +252,7 @@ linters:
# - wastedassign
- staticcheck
- tenv
# In Go, it's possible for a package to test it's internal functionality
# without testing any exported functions. This is enabled to promote
# decomposing a package before testing it's internals. A function caller
@@ -274,5 +265,4 @@ linters:
- typecheck
- unconvert
- unused
- usetesting
- dupl
+324 -12
View File
@@ -29,6 +29,7 @@ import (
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/httpapi"
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
@@ -39,6 +40,7 @@ import (
"github.com/coder/coder/v2/scaletest/dashboard"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/coder/v2/scaletest/notifications"
"github.com/coder/coder/v2/scaletest/reconnectingpty"
"github.com/coder/coder/v2/scaletest/workspacebuild"
"github.com/coder/coder/v2/scaletest/workspacetraffic"
@@ -64,7 +66,6 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
r.scaletestWorkspaceTraffic(),
r.scaletestAutostart(),
r.scaletestNotifications(),
r.scaletestSMTP(),
},
}
@@ -1920,6 +1921,259 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
return cmd
}
func (r *RootCmd) scaletestNotifications() *serpent.Command {
var (
userCount int64
ownerUserPercentage float64
notificationTimeout time.Duration
dialTimeout time.Duration
noCleanup bool
tracingFlags = &scaletestTracingFlags{}
// This test requires unlimited concurrency.
timeoutStrategy = &timeoutFlags{}
cleanupStrategy = newScaletestCleanupStrategy()
output = &scaletestOutputFlags{}
prometheusFlags = &scaletestPrometheusFlags{}
)
cmd := &serpent.Command{
Use: "notifications",
Short: "Simulate notification delivery by creating many users listening to notifications.",
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
defer stop()
ctx = notifyCtx
me, err := requireAdmin(ctx, client)
if err != nil {
return err
}
client.HTTPClient = &http.Client{
Transport: &codersdk.HeaderTransport{
Transport: http.DefaultTransport,
Header: map[string][]string{
codersdk.BypassRatelimitHeader: {"true"},
},
},
}
if userCount <= 0 {
return xerrors.Errorf("--user-count must be greater than 0")
}
if ownerUserPercentage < 0 || ownerUserPercentage > 100 {
return xerrors.Errorf("--owner-user-percentage must be between 0 and 100")
}
ownerUserCount := int64(float64(userCount) * ownerUserPercentage / 100)
if ownerUserCount == 0 && ownerUserPercentage > 0 {
ownerUserCount = 1
}
regularUserCount := userCount - ownerUserCount
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
_, _ = fmt.Fprintf(inv.Stderr, " Owner users: %d (%.1f%%)\n", ownerUserCount, ownerUserPercentage)
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-ownerUserPercentage)
outputs, err := output.parse()
if err != nil {
return xerrors.Errorf("could not parse --output flags")
}
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
if err != nil {
return xerrors.Errorf("create tracer provider: %w", err)
}
tracer := tracerProvider.Tracer(scaletestTracerName)
reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
logger := inv.Logger
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
defer prometheusSrvClose()
defer func() {
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
if err := closeTracing(ctx); err != nil {
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
}
// Wait for prometheus metrics to be scraped
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
<-time.After(prometheusFlags.Wait)
}()
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
dialBarrier := &sync.WaitGroup{}
ownerWatchBarrier := &sync.WaitGroup{}
dialBarrier.Add(int(userCount))
ownerWatchBarrier.Add(int(ownerUserCount))
expectedNotifications := map[uuid.UUID]chan time.Time{
notificationsLib.TemplateUserAccountCreated: make(chan time.Time, 1),
notificationsLib.TemplateUserAccountDeleted: make(chan time.Time, 1),
}
configs := make([]notifications.Config, 0, userCount)
for range ownerUserCount {
config := notifications.Config{
User: createusers.Config{
OrganizationID: me.OrganizationIDs[0],
},
Roles: []string{codersdk.RoleOwner},
NotificationTimeout: notificationTimeout,
DialTimeout: dialTimeout,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: ownerWatchBarrier,
ExpectedNotifications: expectedNotifications,
Metrics: metrics,
}
if err := config.Validate(); err != nil {
return xerrors.Errorf("validate config: %w", err)
}
configs = append(configs, config)
}
for range regularUserCount {
config := notifications.Config{
User: createusers.Config{
OrganizationID: me.OrganizationIDs[0],
},
Roles: []string{},
NotificationTimeout: notificationTimeout,
DialTimeout: dialTimeout,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: ownerWatchBarrier,
Metrics: metrics,
}
if err := config.Validate(); err != nil {
return xerrors.Errorf("validate config: %w", err)
}
configs = append(configs, config)
}
go triggerUserNotifications(
ctx,
logger,
client,
me.OrganizationIDs[0],
dialBarrier,
dialTimeout,
expectedNotifications,
)
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
for i, config := range configs {
id := strconv.Itoa(i)
name := fmt.Sprintf("notifications-%s", id)
var runner harness.Runnable = notifications.NewRunner(client, config)
if tracingEnabled {
runner = &runnableTraceWrapper{
tracer: tracer,
spanName: name,
runner: runner,
}
}
th.AddRun(name, id, runner)
}
_, _ = fmt.Fprintln(inv.Stderr, "Running notification delivery scaletest...")
testCtx, testCancel := timeoutStrategy.toContext(ctx)
defer testCancel()
err = th.Run(testCtx)
if err != nil {
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
}
// If the command was interrupted, skip stats.
if notifyCtx.Err() != nil {
return notifyCtx.Err()
}
res := th.Results()
for _, o := range outputs {
err = o.write(res, inv.Stdout)
if err != nil {
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
}
}
if !noCleanup {
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
defer cleanupCancel()
err = th.Cleanup(cleanupCtx)
if err != nil {
return xerrors.Errorf("cleanup tests: %w", err)
}
}
if res.TotalFail > 0 {
return xerrors.New("load test failed, see above for more details")
}
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "user-count",
FlagShorthand: "c",
Env: "CODER_SCALETEST_NOTIFICATION_USER_COUNT",
Description: "Required: Total number of users to create.",
Value: serpent.Int64Of(&userCount),
Required: true,
},
{
Flag: "owner-user-percentage",
Env: "CODER_SCALETEST_NOTIFICATION_OWNER_USER_PERCENTAGE",
Default: "20.0",
Description: "Percentage of users to assign Owner role to (0-100).",
Value: serpent.Float64Of(&ownerUserPercentage),
},
{
Flag: "notification-timeout",
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
Default: "5m",
Description: "How long to wait for notifications after triggering.",
Value: serpent.DurationOf(&notificationTimeout),
},
{
Flag: "dial-timeout",
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
Default: "2m",
Description: "Timeout for dialing the notification websocket endpoint.",
Value: serpent.DurationOf(&dialTimeout),
},
{
Flag: "no-cleanup",
Env: "CODER_SCALETEST_NO_CLEANUP",
Description: "Do not clean up resources after the test completes.",
Value: serpent.BoolOf(&noCleanup),
},
}
tracingFlags.attach(&cmd.Options)
timeoutStrategy.attach(&cmd.Options)
cleanupStrategy.attach(&cmd.Options)
output.attach(&cmd.Options)
prometheusFlags.attach(&cmd.Options)
return cmd
}
type runnableTraceWrapper struct {
tracer trace.Tracer
spanName string
@@ -1929,9 +2183,8 @@ type runnableTraceWrapper struct {
}
var (
_ harness.Runnable = &runnableTraceWrapper{}
_ harness.Cleanable = &runnableTraceWrapper{}
_ harness.Collectable = &runnableTraceWrapper{}
_ harness.Runnable = &runnableTraceWrapper{}
_ harness.Cleanable = &runnableTraceWrapper{}
)
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
@@ -1973,14 +2226,6 @@ func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string, logs io.W
return c.Cleanup(ctx, id, logs)
}
func (r *runnableTraceWrapper) GetMetrics() map[string]any {
c, ok := r.runner.(harness.Collectable)
if !ok {
return nil
}
return c.GetMetrics()
}
func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) {
var (
pageNumber = 0
@@ -2129,6 +2374,73 @@ func parseTargetRange(name, targets string) (start, end int, err error) {
return start, end, nil
}
// triggerUserNotifications waits for all test users to connect,
// then creates and deletes a test user to trigger notification events for testing.
func triggerUserNotifications(
ctx context.Context,
logger slog.Logger,
client *codersdk.Client,
orgID uuid.UUID,
dialBarrier *sync.WaitGroup,
dialTimeout time.Duration,
expectedNotifications map[uuid.UUID]chan time.Time,
) {
logger.Info(ctx, "waiting for all users to connect")
// Wait for all users to connect
waitCtx, cancel := context.WithTimeout(ctx, dialTimeout+30*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
dialBarrier.Wait()
close(done)
}()
select {
case <-done:
logger.Info(ctx, "all users connected")
case <-waitCtx.Done():
if waitCtx.Err() == context.DeadlineExceeded {
logger.Error(ctx, "timeout waiting for users to connect")
} else {
logger.Info(ctx, "context canceled while waiting for users")
}
return
}
const (
triggerUsername = "scaletest-trigger-user"
triggerEmail = "scaletest-trigger@example.com"
)
logger.Info(ctx, "creating test user to test notifications",
slog.F("username", triggerUsername),
slog.F("email", triggerEmail),
slog.F("org_id", orgID))
testUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{orgID},
Username: triggerUsername,
Email: triggerEmail,
Password: "test-password-123",
})
if err != nil {
logger.Error(ctx, "create test user", slog.Error(err))
return
}
expectedNotifications[notificationsLib.TemplateUserAccountCreated] <- time.Now()
err = client.DeleteUser(ctx, testUser.ID)
if err != nil {
logger.Error(ctx, "delete test user", slog.Error(err))
return
}
expectedNotifications[notificationsLib.TemplateUserAccountDeleted] <- time.Now()
close(expectedNotifications[notificationsLib.TemplateUserAccountCreated])
close(expectedNotifications[notificationsLib.TemplateUserAccountDeleted])
}
func createWorkspaceAppConfig(client *codersdk.Client, appHost, app string, workspace codersdk.Workspace, agent codersdk.WorkspaceAgent) (workspacetraffic.AppConfig, error) {
if app == "" {
return workspacetraffic.AppConfig{}, nil
-447
View File
@@ -1,447 +0,0 @@
//go:build !slim
package cli
import (
"context"
"fmt"
"net/http"
"os/signal"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"cdr.dev/slog"
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scaletest/createusers"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/notifications"
"github.com/coder/serpent"
)
func (r *RootCmd) scaletestNotifications() *serpent.Command {
var (
userCount int64
ownerUserPercentage float64
notificationTimeout time.Duration
dialTimeout time.Duration
noCleanup bool
smtpAPIURL string
tracingFlags = &scaletestTracingFlags{}
// This test requires unlimited concurrency.
timeoutStrategy = &timeoutFlags{}
cleanupStrategy = newScaletestCleanupStrategy()
output = &scaletestOutputFlags{}
prometheusFlags = &scaletestPrometheusFlags{}
)
cmd := &serpent.Command{
Use: "notifications",
Short: "Simulate notification delivery by creating many users listening to notifications.",
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
defer stop()
ctx = notifyCtx
me, err := requireAdmin(ctx, client)
if err != nil {
return err
}
client.HTTPClient = &http.Client{
Transport: &codersdk.HeaderTransport{
Transport: http.DefaultTransport,
Header: map[string][]string{
codersdk.BypassRatelimitHeader: {"true"},
},
},
}
if userCount <= 0 {
return xerrors.Errorf("--user-count must be greater than 0")
}
if ownerUserPercentage < 0 || ownerUserPercentage > 100 {
return xerrors.Errorf("--owner-user-percentage must be between 0 and 100")
}
if smtpAPIURL != "" && !strings.HasPrefix(smtpAPIURL, "http://") && !strings.HasPrefix(smtpAPIURL, "https://") {
return xerrors.Errorf("--smtp-api-url must start with http:// or https://")
}
ownerUserCount := int64(float64(userCount) * ownerUserPercentage / 100)
if ownerUserCount == 0 && ownerUserPercentage > 0 {
ownerUserCount = 1
}
regularUserCount := userCount - ownerUserCount
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
_, _ = fmt.Fprintf(inv.Stderr, " Owner users: %d (%.1f%%)\n", ownerUserCount, ownerUserPercentage)
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-ownerUserPercentage)
outputs, err := output.parse()
if err != nil {
return xerrors.Errorf("could not parse --output flags")
}
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
if err != nil {
return xerrors.Errorf("create tracer provider: %w", err)
}
tracer := tracerProvider.Tracer(scaletestTracerName)
reg := prometheus.NewRegistry()
metrics := notifications.NewMetrics(reg)
logger := inv.Logger
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
defer prometheusSrvClose()
defer func() {
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
if err := closeTracing(ctx); err != nil {
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
}
// Wait for prometheus metrics to be scraped
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
<-time.After(prometheusFlags.Wait)
}()
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
dialBarrier := &sync.WaitGroup{}
ownerWatchBarrier := &sync.WaitGroup{}
dialBarrier.Add(int(userCount))
ownerWatchBarrier.Add(int(ownerUserCount))
expectedNotificationIDs := map[uuid.UUID]struct{}{
notificationsLib.TemplateUserAccountCreated: {},
notificationsLib.TemplateUserAccountDeleted: {},
}
triggerTimes := make(map[uuid.UUID]chan time.Time, len(expectedNotificationIDs))
for id := range expectedNotificationIDs {
triggerTimes[id] = make(chan time.Time, 1)
}
configs := make([]notifications.Config, 0, userCount)
for range ownerUserCount {
config := notifications.Config{
User: createusers.Config{
OrganizationID: me.OrganizationIDs[0],
},
Roles: []string{codersdk.RoleOwner},
NotificationTimeout: notificationTimeout,
DialTimeout: dialTimeout,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: ownerWatchBarrier,
ExpectedNotificationsIDs: expectedNotificationIDs,
Metrics: metrics,
SMTPApiURL: smtpAPIURL,
}
if err := config.Validate(); err != nil {
return xerrors.Errorf("validate config: %w", err)
}
configs = append(configs, config)
}
for range regularUserCount {
config := notifications.Config{
User: createusers.Config{
OrganizationID: me.OrganizationIDs[0],
},
Roles: []string{},
NotificationTimeout: notificationTimeout,
DialTimeout: dialTimeout,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: ownerWatchBarrier,
Metrics: metrics,
SMTPApiURL: smtpAPIURL,
}
if err := config.Validate(); err != nil {
return xerrors.Errorf("validate config: %w", err)
}
configs = append(configs, config)
}
go triggerUserNotifications(
ctx,
logger,
client,
me.OrganizationIDs[0],
dialBarrier,
dialTimeout,
triggerTimes,
)
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
for i, config := range configs {
id := strconv.Itoa(i)
name := fmt.Sprintf("notifications-%s", id)
var runner harness.Runnable = notifications.NewRunner(client, config)
if tracingEnabled {
runner = &runnableTraceWrapper{
tracer: tracer,
spanName: name,
runner: runner,
}
}
th.AddRun(name, id, runner)
}
_, _ = fmt.Fprintln(inv.Stderr, "Running notification delivery scaletest...")
testCtx, testCancel := timeoutStrategy.toContext(ctx)
defer testCancel()
err = th.Run(testCtx)
if err != nil {
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
}
// If the command was interrupted, skip stats.
if notifyCtx.Err() != nil {
return notifyCtx.Err()
}
res := th.Results()
if err := computeNotificationLatencies(ctx, logger, triggerTimes, res, metrics); err != nil {
return xerrors.Errorf("compute notification latencies: %w", err)
}
for _, o := range outputs {
err = o.write(res, inv.Stdout)
if err != nil {
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
}
}
if !noCleanup {
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
defer cleanupCancel()
err = th.Cleanup(cleanupCtx)
if err != nil {
return xerrors.Errorf("cleanup tests: %w", err)
}
}
if res.TotalFail > 0 {
return xerrors.New("load test failed, see above for more details")
}
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Flag: "user-count",
FlagShorthand: "c",
Env: "CODER_SCALETEST_NOTIFICATION_USER_COUNT",
Description: "Required: Total number of users to create.",
Value: serpent.Int64Of(&userCount),
Required: true,
},
{
Flag: "owner-user-percentage",
Env: "CODER_SCALETEST_NOTIFICATION_OWNER_USER_PERCENTAGE",
Default: "20.0",
Description: "Percentage of users to assign Owner role to (0-100).",
Value: serpent.Float64Of(&ownerUserPercentage),
},
{
Flag: "notification-timeout",
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
Default: "5m",
Description: "How long to wait for notifications after triggering.",
Value: serpent.DurationOf(&notificationTimeout),
},
{
Flag: "dial-timeout",
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
Default: "2m",
Description: "Timeout for dialing the notification websocket endpoint.",
Value: serpent.DurationOf(&dialTimeout),
},
{
Flag: "no-cleanup",
Env: "CODER_SCALETEST_NO_CLEANUP",
Description: "Do not clean up resources after the test completes.",
Value: serpent.BoolOf(&noCleanup),
},
{
Flag: "smtp-api-url",
Env: "CODER_SCALETEST_SMTP_API_URL",
Description: "SMTP mock HTTP API address.",
Value: serpent.StringOf(&smtpAPIURL),
},
}
tracingFlags.attach(&cmd.Options)
timeoutStrategy.attach(&cmd.Options)
cleanupStrategy.attach(&cmd.Options)
output.attach(&cmd.Options)
prometheusFlags.attach(&cmd.Options)
return cmd
}
func computeNotificationLatencies(
ctx context.Context,
logger slog.Logger,
expectedNotifications map[uuid.UUID]chan time.Time,
results harness.Results,
metrics *notifications.Metrics,
) error {
triggerTimes := make(map[uuid.UUID]time.Time)
for notificationID, triggerTimeChan := range expectedNotifications {
select {
case triggerTime := <-triggerTimeChan:
triggerTimes[notificationID] = triggerTime
logger.Info(ctx, "received trigger time",
slog.F("notification_id", notificationID),
slog.F("trigger_time", triggerTime))
default:
logger.Warn(ctx, "no trigger time received for notification",
slog.F("notification_id", notificationID))
}
}
if len(triggerTimes) == 0 {
logger.Warn(ctx, "no trigger times available, skipping latency computation")
return nil
}
var totalLatencies int
for runID, runResult := range results.Runs {
if runResult.Error != nil {
logger.Debug(ctx, "skipping failed run for latency computation",
slog.F("run_id", runID))
continue
}
if runResult.Metrics == nil {
continue
}
// Process websocket notifications.
if wsReceiptTimes, ok := runResult.Metrics[notifications.WebsocketNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
for notificationID, receiptTime := range wsReceiptTimes {
if triggerTime, ok := triggerTimes[notificationID]; ok {
latency := receiptTime.Sub(triggerTime)
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeWebsocket)
totalLatencies++
logger.Debug(ctx, "computed websocket latency",
slog.F("run_id", runID),
slog.F("notification_id", notificationID),
slog.F("latency", latency))
}
}
}
// Process SMTP notifications
if smtpReceiptTimes, ok := runResult.Metrics[notifications.SMTPNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
for notificationID, receiptTime := range smtpReceiptTimes {
if triggerTime, ok := triggerTimes[notificationID]; ok {
latency := receiptTime.Sub(triggerTime)
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeSMTP)
totalLatencies++
logger.Debug(ctx, "computed SMTP latency",
slog.F("run_id", runID),
slog.F("notification_id", notificationID),
slog.F("latency", latency))
}
}
}
}
logger.Info(ctx, "finished computing notification latencies",
slog.F("total_runs", results.TotalRuns),
slog.F("total_latencies_computed", totalLatencies))
return nil
}
// triggerUserNotifications waits for all test users to connect,
// then creates and deletes a test user to trigger notification events for testing.
func triggerUserNotifications(
ctx context.Context,
logger slog.Logger,
client *codersdk.Client,
orgID uuid.UUID,
dialBarrier *sync.WaitGroup,
dialTimeout time.Duration,
expectedNotifications map[uuid.UUID]chan time.Time,
) {
logger.Info(ctx, "waiting for all users to connect")
// Wait for all users to connect
waitCtx, cancel := context.WithTimeout(ctx, dialTimeout+30*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
dialBarrier.Wait()
close(done)
}()
select {
case <-done:
logger.Info(ctx, "all users connected")
case <-waitCtx.Done():
if waitCtx.Err() == context.DeadlineExceeded {
logger.Error(ctx, "timeout waiting for users to connect")
} else {
logger.Info(ctx, "context canceled while waiting for users")
}
return
}
const (
triggerUsername = "scaletest-trigger-user"
triggerEmail = "scaletest-trigger@example.com"
)
logger.Info(ctx, "creating test user to test notifications",
slog.F("username", triggerUsername),
slog.F("email", triggerEmail),
slog.F("org_id", orgID))
testUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{orgID},
Username: triggerUsername,
Email: triggerEmail,
Password: "test-password-123",
})
if err != nil {
logger.Error(ctx, "create test user", slog.Error(err))
return
}
expectedNotifications[notificationsLib.TemplateUserAccountCreated] <- time.Now()
err = client.DeleteUser(ctx, testUser.ID)
if err != nil {
logger.Error(ctx, "delete test user", slog.Error(err))
return
}
expectedNotifications[notificationsLib.TemplateUserAccountDeleted] <- time.Now()
close(expectedNotifications[notificationsLib.TemplateUserAccountCreated])
close(expectedNotifications[notificationsLib.TemplateUserAccountDeleted])
}
-112
View File
@@ -1,112 +0,0 @@
//go:build !slim
package cli
import (
"fmt"
"os/signal"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/scaletest/smtpmock"
"github.com/coder/serpent"
)
func (*RootCmd) scaletestSMTP() *serpent.Command {
var (
hostAddress string
smtpPort int64
apiPort int64
purgeAtCount int64
)
cmd := &serpent.Command{
Use: "smtp",
Short: "Start a mock SMTP server for testing",
Long: `Start a mock SMTP server with an HTTP API server that can be used to purge
messages and get messages by email.`,
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
defer stop()
ctx = notifyCtx
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
config := smtpmock.Config{
HostAddress: hostAddress,
SMTPPort: int(smtpPort),
APIPort: int(apiPort),
Logger: logger,
}
srv := new(smtpmock.Server)
if err := srv.Start(ctx, config); err != nil {
return xerrors.Errorf("start mock SMTP server: %w", err)
}
defer func() {
_ = srv.Stop()
}()
_, _ = fmt.Fprintf(inv.Stdout, "Mock SMTP server started on %s\n", srv.SMTPAddress())
_, _ = fmt.Fprintf(inv.Stdout, "HTTP API server started on %s\n", srv.APIAddress())
if purgeAtCount > 0 {
_, _ = fmt.Fprintf(inv.Stdout, " Auto-purge when message count reaches %d\n", purgeAtCount)
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
_, _ = fmt.Fprintf(inv.Stdout, "\nTotal messages received since last purge: %d\n", srv.MessageCount())
return nil
case <-ticker.C:
count := srv.MessageCount()
if count > 0 {
_, _ = fmt.Fprintf(inv.Stdout, "Messages received: %d\n", count)
}
if purgeAtCount > 0 && int64(count) >= purgeAtCount {
_, _ = fmt.Fprintf(inv.Stdout, "Message count (%d) reached threshold (%d). Purging...\n", count, purgeAtCount)
srv.Purge()
continue
}
}
}
},
}
cmd.Options = []serpent.Option{
{
Flag: "host-address",
Env: "CODER_SCALETEST_SMTP_HOST_ADDRESS",
Default: "localhost",
Description: "Host address to bind the mock SMTP and API servers.",
Value: serpent.StringOf(&hostAddress),
},
{
Flag: "smtp-port",
Env: "CODER_SCALETEST_SMTP_PORT",
Description: "Port for the mock SMTP server. Uses a random port if not specified.",
Value: serpent.Int64Of(&smtpPort),
},
{
Flag: "api-port",
Env: "CODER_SCALETEST_SMTP_API_PORT",
Description: "Port for the HTTP API server. Uses a random port if not specified.",
Value: serpent.Int64Of(&apiPort),
},
{
Flag: "purge-at-count",
Env: "CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
Default: "100000",
Description: "Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
Value: serpent.Int64Of(&purgeAtCount),
},
}
return cmd
}
+39 -75
View File
@@ -29,7 +29,6 @@ import (
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/charmbracelet/lipgloss"
@@ -1378,7 +1377,6 @@ func IsLocalURL(ctx context.Context, u *url.URL) (bool, error) {
}
func shutdownWithTimeout(shutdown func(context.Context) error, timeout time.Duration) error {
// nolint:gocritic // The magic number is parameterized.
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return shutdown(ctx)
@@ -2136,83 +2134,50 @@ func startBuiltinPostgres(ctx context.Context, cfg config.Root, logger slog.Logg
return "", nil, xerrors.New("The built-in PostgreSQL cannot run as the root user. Create a non-root user and run again!")
}
// Ensure a password and port have been generated!
connectionURL, err := embeddedPostgresURL(cfg)
if err != nil {
return "", nil, err
}
pgPassword, err := cfg.PostgresPassword().Read()
if err != nil {
return "", nil, xerrors.Errorf("read postgres password: %w", err)
}
pgPortRaw, err := cfg.PostgresPort().Read()
if err != nil {
return "", nil, xerrors.Errorf("read postgres port: %w", err)
}
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
if err != nil {
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
}
cachePath := filepath.Join(cfg.PostgresPath(), "cache")
if customCacheDir != "" {
cachePath = filepath.Join(customCacheDir, "postgres")
}
stdlibLogger := slog.Stdlib(ctx, logger.Named("postgres"), slog.LevelDebug)
// If the port is not defined, an available port will be found dynamically.
maxAttempts := 1
_, err = cfg.PostgresPort().Read()
retryPortDiscovery := errors.Is(err, os.ErrNotExist) && testing.Testing()
if retryPortDiscovery {
// There is no way to tell Postgres to use an ephemeral port, so in order to avoid
// flaky tests in CI we need to retry EmbeddedPostgres.Start in case of a race
// condition where the port we quickly listen on and close in embeddedPostgresURL()
// is not free by the time the embedded postgres starts up. This maximum_should
// cover most cases where port conflicts occur in CI and cause flaky tests.
maxAttempts = 3
ep := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().
Version(embeddedpostgres.V13).
BinariesPath(filepath.Join(cfg.PostgresPath(), "bin")).
// Default BinaryRepositoryURL repo1.maven.org is flaky.
BinaryRepositoryURL("https://repo.maven.apache.org/maven2").
DataPath(filepath.Join(cfg.PostgresPath(), "data")).
RuntimePath(filepath.Join(cfg.PostgresPath(), "runtime")).
CachePath(cachePath).
Username("coder").
Password(pgPassword).
Database("coder").
Encoding("UTF8").
Port(uint32(pgPort)).
Logger(stdlibLogger.Writer()),
)
err = ep.Start()
if err != nil {
return "", nil, xerrors.Errorf("Failed to start built-in PostgreSQL. Optionally, specify an external deployment with `--postgres-url`: %w", err)
}
var startErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
// Ensure a password and port have been generated.
connectionURL, err := embeddedPostgresURL(cfg)
if err != nil {
return "", nil, err
}
pgPassword, err := cfg.PostgresPassword().Read()
if err != nil {
return "", nil, xerrors.Errorf("read postgres password: %w", err)
}
pgPortRaw, err := cfg.PostgresPort().Read()
if err != nil {
return "", nil, xerrors.Errorf("read postgres port: %w", err)
}
pgPort, err := strconv.ParseUint(pgPortRaw, 10, 16)
if err != nil {
return "", nil, xerrors.Errorf("parse postgres port: %w", err)
}
ep := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().
Version(embeddedpostgres.V13).
BinariesPath(filepath.Join(cfg.PostgresPath(), "bin")).
// Default BinaryRepositoryURL repo1.maven.org is flaky.
BinaryRepositoryURL("https://repo.maven.apache.org/maven2").
DataPath(filepath.Join(cfg.PostgresPath(), "data")).
RuntimePath(filepath.Join(cfg.PostgresPath(), "runtime")).
CachePath(cachePath).
Username("coder").
Password(pgPassword).
Database("coder").
Encoding("UTF8").
Port(uint32(pgPort)).
Logger(stdlibLogger.Writer()),
)
startErr = ep.Start()
if startErr == nil {
return connectionURL, ep.Stop, nil
}
logger.Warn(ctx, "failed to start embedded postgres",
slog.F("attempt", attempt+1),
slog.F("max_attempts", maxAttempts),
slog.F("port", pgPort),
slog.Error(startErr),
)
if retryPortDiscovery {
// Since a retry is needed, we wipe the port stored here at the beginning of the loop.
_ = cfg.PostgresPort().Delete()
}
}
return "", nil, xerrors.Errorf("failed to start built-in PostgreSQL after %d attempts. "+
"Optionally, specify an external deployment. See https://coder.com/docs/tutorials/external-database "+
"for more details: %w", maxAttempts, startErr)
return connectionURL, ep.Stop, nil
}
func ConfigureHTTPClient(ctx context.Context, clientCertFile, clientKeyFile string, tlsClientCAFile string) (context.Context, *http.Client, error) {
@@ -2321,7 +2286,7 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
var err error
var sqlDB *sql.DB
dbNeedsClosing := true
// nolint:gocritic // Try to connect for 30 seconds.
// Try to connect for 30 seconds.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
@@ -2417,7 +2382,6 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
}
func pingPostgres(ctx context.Context, db *sql.DB) error {
// nolint:gocritic // This is a reasonable magic number for a ping timeout.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return db.PingContext(ctx)
-4
View File
@@ -54,7 +54,6 @@ func TestSharingShare(t *testing.T) {
MinimalUser: codersdk.MinimalUser{
ID: toShareWithUser.ID,
Username: toShareWithUser.Username,
Name: toShareWithUser.Name,
AvatarURL: toShareWithUser.AvatarURL,
},
Role: codersdk.WorkspaceRole("use"),
@@ -104,7 +103,6 @@ func TestSharingShare(t *testing.T) {
MinimalUser: codersdk.MinimalUser{
ID: toShareWithUser1.ID,
Username: toShareWithUser1.Username,
Name: toShareWithUser1.Name,
AvatarURL: toShareWithUser1.AvatarURL,
},
Role: codersdk.WorkspaceRoleUse,
@@ -113,7 +111,6 @@ func TestSharingShare(t *testing.T) {
MinimalUser: codersdk.MinimalUser{
ID: toShareWithUser2.ID,
Username: toShareWithUser2.Username,
Name: toShareWithUser2.Name,
AvatarURL: toShareWithUser2.AvatarURL,
},
Role: codersdk.WorkspaceRoleUse,
@@ -158,7 +155,6 @@ func TestSharingShare(t *testing.T) {
MinimalUser: codersdk.MinimalUser{
ID: toShareWithUser.ID,
Username: toShareWithUser.Username,
Name: toShareWithUser.Name,
AvatarURL: toShareWithUser.AvatarURL,
},
Role: codersdk.WorkspaceRoleAdmin,
-5
View File
@@ -16,10 +16,6 @@ USAGE:
$ coder tokens ls
- Create a scoped token:
$ coder tokens create --scope workspace:read --allow workspace:<uuid>
- Remove a token by ID:
$ coder tokens rm WuoWs4ZsMX
@@ -28,7 +24,6 @@ SUBCOMMANDS:
create Create a token
list List tokens
remove Delete a token
view Display detailed information about a token
———
Run `coder --help` for a list of global options.
-6
View File
@@ -6,18 +6,12 @@ USAGE:
Create a token
OPTIONS:
--allow allowList
Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).
--lifetime string, $CODER_TOKEN_LIFETIME
Specify a duration for the lifetime of the token.
-n, --name string, $CODER_TOKEN_NAME
Specify a human-readable name.
--scope scope
Repeatable scope to attach to the token (e.g. workspace:read).
-u, --user string, $CODER_TOKEN_USER
Specify the user to create the token for (Only works if logged in user
is admin).
+1 -1
View File
@@ -12,7 +12,7 @@ OPTIONS:
Specifies whether all users' tokens will be listed or not (must have
Owner role to see all tokens).
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at)
-c, --column [id|name|last used|expires at|created at|owner] (default: id,name,last used,expires at,created at)
Columns to display in table output.
-o, --output table|json (default: table)
-16
View File
@@ -1,16 +0,0 @@
coder v0.0.0-devel
USAGE:
coder tokens view [flags] <name|id>
Display detailed information about a token
OPTIONS:
-c, --column [id|name|scopes|allow list|last used|expires at|created at|owner] (default: id,name,scopes,allow list,last used,expires at,created at,owner)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -8,7 +8,7 @@ USAGE:
Aliases: ls
OPTIONS:
-c, --column [id|username|name|email|created at|updated at|status] (default: username,email,created at,status)
-c, --column [id|username|email|created at|updated at|status] (default: username,email,created at,status)
Columns to display in table output.
--github-user-id int
+5 -105
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"slices"
"sort"
"strings"
"time"
@@ -28,10 +27,6 @@ func (r *RootCmd) tokens() *serpent.Command {
Description: "List your tokens",
Command: "coder tokens ls",
},
Example{
Description: "Create a scoped token",
Command: "coder tokens create --scope workspace:read --allow workspace:<uuid>",
},
Example{
Description: "Remove a token by ID",
Command: "coder tokens rm WuoWs4ZsMX",
@@ -44,7 +39,6 @@ func (r *RootCmd) tokens() *serpent.Command {
Children: []*serpent.Command{
r.createToken(),
r.listTokens(),
r.viewToken(),
r.removeToken(),
},
}
@@ -56,8 +50,6 @@ func (r *RootCmd) createToken() *serpent.Command {
tokenLifetime string
name string
user string
scopes []codersdk.APIKeyScope
allowList []codersdk.APIAllowListTarget
)
cmd := &serpent.Command{
Use: "create",
@@ -96,18 +88,10 @@ func (r *RootCmd) createToken() *serpent.Command {
}
}
req := codersdk.CreateTokenRequest{
res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{
Lifetime: parsedLifetime,
TokenName: name,
}
if len(scopes) > 0 {
req.Scopes = append([]codersdk.APIKeyScope(nil), scopes...)
}
if len(allowList) > 0 {
req.AllowList = append([]codersdk.APIAllowListTarget(nil), allowList...)
}
res, err := client.CreateToken(inv.Context(), userID, req)
})
if err != nil {
return xerrors.Errorf("create tokens: %w", err)
}
@@ -139,16 +123,6 @@ func (r *RootCmd) createToken() *serpent.Command {
Description: "Specify the user to create the token for (Only works if logged in user is admin).",
Value: serpent.StringOf(&user),
},
{
Flag: "scope",
Description: "Repeatable scope to attach to the token (e.g. workspace:read).",
Value: newScopeFlag(&scopes),
},
{
Flag: "allow",
Description: "Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).",
Value: newAllowListFlag(&allowList),
},
}
return cmd
@@ -162,8 +136,6 @@ type tokenListRow struct {
// For table format:
ID string `json:"-" table:"id,default_sort"`
TokenName string `json:"token_name" table:"name"`
Scopes string `json:"-" table:"scopes"`
Allow string `json:"-" table:"allow list"`
LastUsed time.Time `json:"-" table:"last used"`
ExpiresAt time.Time `json:"-" table:"expires at"`
CreatedAt time.Time `json:"-" table:"created at"`
@@ -171,50 +143,20 @@ type tokenListRow struct {
}
func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow {
return tokenListRowFromKey(token.APIKey, token.Username)
}
func tokenListRowFromKey(token codersdk.APIKey, owner string) tokenListRow {
return tokenListRow{
APIKey: token,
APIKey: token.APIKey,
ID: token.ID,
TokenName: token.TokenName,
Scopes: joinScopes(token.Scopes),
Allow: joinAllowList(token.AllowList),
LastUsed: token.LastUsed,
ExpiresAt: token.ExpiresAt,
CreatedAt: token.CreatedAt,
Owner: owner,
Owner: token.Username,
}
}
func joinScopes(scopes []codersdk.APIKeyScope) string {
if len(scopes) == 0 {
return ""
}
vals := make([]string, len(scopes))
for i, scope := range scopes {
vals[i] = string(scope)
}
sort.Strings(vals)
return strings.Join(vals, ", ")
}
func joinAllowList(entries []codersdk.APIAllowListTarget) string {
if len(entries) == 0 {
return ""
}
vals := make([]string, len(entries))
for i, entry := range entries {
vals[i] = entry.String()
}
sort.Strings(vals)
return strings.Join(vals, ", ")
}
func (r *RootCmd) listTokens() *serpent.Command {
// we only display the 'owner' column if the --all argument is passed in
defaultCols := []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at"}
defaultCols := []string{"id", "name", "last used", "expires at", "created at"}
if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") {
defaultCols = append(defaultCols, "owner")
}
@@ -284,48 +226,6 @@ func (r *RootCmd) listTokens() *serpent.Command {
return cmd
}
func (r *RootCmd) viewToken() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.TableFormat([]tokenListRow{}, []string{"id", "name", "scopes", "allow list", "last used", "expires at", "created at", "owner"}),
cliui.JSONFormat(),
)
cmd := &serpent.Command{
Use: "view <name|id>",
Short: "Display detailed information about a token",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
tokenName := inv.Args[0]
token, err := client.APIKeyByName(inv.Context(), codersdk.Me, tokenName)
if err != nil {
maybeID := strings.Split(tokenName, "-")[0]
token, err = client.APIKeyByID(inv.Context(), codersdk.Me, maybeID)
if err != nil {
return xerrors.Errorf("fetch api key by name or id: %w", err)
}
}
row := tokenListRowFromKey(*token, "")
out, err := formatter.Format(inv.Context(), []tokenListRow{row})
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
func (r *RootCmd) removeToken() *serpent.Command {
cmd := &serpent.Command{
Use: "remove <name|id|token>",
-85
View File
@@ -1,85 +0,0 @@
package cli
import (
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
// allowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries.
type allowListFlag struct {
targets *[]codersdk.APIAllowListTarget
}
func newAllowListFlag(dst *[]codersdk.APIAllowListTarget) *allowListFlag {
return &allowListFlag{targets: dst}
}
func (a *allowListFlag) ensureSlice() error {
if a.targets == nil {
return xerrors.New("allow list destination is nil")
}
if *a.targets == nil {
*a.targets = make([]codersdk.APIAllowListTarget, 0)
}
return nil
}
func (a *allowListFlag) String() string {
if a.targets == nil || len(*a.targets) == 0 {
return ""
}
parts := make([]string, len(*a.targets))
for i, t := range *a.targets {
parts[i] = t.String()
}
return strings.Join(parts, ",")
}
func (a *allowListFlag) Set(raw string) error {
if err := a.ensureSlice(); err != nil {
return err
}
raw = strings.TrimSpace(raw)
if raw == "" {
return xerrors.New("allow list entry cannot be empty")
}
var target codersdk.APIAllowListTarget
if err := target.UnmarshalText([]byte(raw)); err != nil {
return err
}
*a.targets = append(*a.targets, target)
return nil
}
func (*allowListFlag) Type() string { return "allowList" }
func (a *allowListFlag) Append(value string) error {
return a.Set(value)
}
func (a *allowListFlag) Replace(items []string) error {
if err := a.ensureSlice(); err != nil {
return err
}
(*a.targets) = (*a.targets)[:0]
for _, item := range items {
if err := a.Set(item); err != nil {
return err
}
}
return nil
}
func (a *allowListFlag) GetSlice() []string {
if a.targets == nil {
return nil
}
out := make([]string, len(*a.targets))
for i, t := range *a.targets {
out[i] = t.String()
}
return out
}
-81
View File
@@ -1,81 +0,0 @@
package cli
import (
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
// scopeFlag stores repeatable --scope values as typed APIKeyScope.
type scopeFlag struct {
scopes *[]codersdk.APIKeyScope
}
func newScopeFlag(dst *[]codersdk.APIKeyScope) *scopeFlag {
return &scopeFlag{scopes: dst}
}
func (s *scopeFlag) ensureSlice() error {
if s.scopes == nil {
return xerrors.New("scope destination is nil")
}
if *s.scopes == nil {
*s.scopes = make([]codersdk.APIKeyScope, 0)
}
return nil
}
func (s *scopeFlag) String() string {
if s.scopes == nil || len(*s.scopes) == 0 {
return ""
}
parts := make([]string, len(*s.scopes))
for i, scope := range *s.scopes {
parts[i] = string(scope)
}
return strings.Join(parts, ",")
}
func (s *scopeFlag) Set(raw string) error {
if err := s.ensureSlice(); err != nil {
return err
}
raw = strings.TrimSpace(raw)
if raw == "" {
return xerrors.New("scope cannot be empty")
}
*s.scopes = append(*s.scopes, codersdk.APIKeyScope(raw))
return nil
}
func (*scopeFlag) Type() string { return "scope" }
func (s *scopeFlag) Append(value string) error {
return s.Set(value)
}
func (s *scopeFlag) Replace(items []string) error {
if err := s.ensureSlice(); err != nil {
return err
}
(*s.scopes) = (*s.scopes)[:0]
for _, item := range items {
if err := s.Set(item); err != nil {
return err
}
}
return nil
}
func (s *scopeFlag) GetSlice() []string {
if s.scopes == nil {
return nil
}
out := make([]string, len(*s.scopes))
for i, scope := range *s.scopes {
out[i] = string(scope)
}
return out
}
+3 -56
View File
@@ -4,13 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/google/uuid"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
@@ -49,18 +46,6 @@ func TestTokens(t *testing.T) {
require.NotEmpty(t, res)
id := res[:10]
allowWorkspaceID := uuid.New()
allowSpec := fmt.Sprintf("workspace:%s", allowWorkspaceID.String())
inv, root = clitest.New(t, "tokens", "create", "--name", "scoped-token", "--scope", string(codersdk.APIKeyScopeWorkspaceRead), "--allow", allowSpec)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
res = buf.String()
require.NotEmpty(t, res)
scopedTokenID := res[:10]
// Test creating a token for second user from first user's (admin) session
inv, root = clitest.New(t, "tokens", "create", "--name", "token-two", "--user", secondUser.ID.String())
clitest.SetupConfig(t, client, root)
@@ -82,7 +67,7 @@ func TestTokens(t *testing.T) {
require.NoError(t, err)
res = buf.String()
require.NotEmpty(t, res)
// Result should only contain the tokens created for the admin user
// Result should only contain the token created for the admin user
require.Contains(t, res, "ID")
require.Contains(t, res, "EXPIRES AT")
require.Contains(t, res, "CREATED AT")
@@ -91,16 +76,6 @@ func TestTokens(t *testing.T) {
// Result should not contain the token created for the second user
require.NotContains(t, res, secondTokenID)
inv, root = clitest.New(t, "tokens", "view", "scoped-token")
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
res = buf.String()
require.Contains(t, res, string(codersdk.APIKeyScopeWorkspaceRead))
require.Contains(t, res, allowSpec)
// Test listing tokens from the second user's session
inv, root = clitest.New(t, "tokens", "ls")
clitest.SetupConfig(t, secondUserClient, root)
@@ -126,14 +101,6 @@ func TestTokens(t *testing.T) {
// User (non-admin) should not be able to create a token for another user
require.Error(t, err)
inv, root = clitest.New(t, "tokens", "create", "--name", "invalid-allow", "--allow", "badvalue")
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.Error(t, err)
require.Contains(t, err.Error(), "invalid allow_list entry")
inv, root = clitest.New(t, "tokens", "ls", "--output=json")
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
@@ -143,17 +110,8 @@ func TestTokens(t *testing.T) {
var tokens []codersdk.APIKey
require.NoError(t, json.Unmarshal(buf.Bytes(), &tokens))
require.Len(t, tokens, 2)
tokenByName := make(map[string]codersdk.APIKey, len(tokens))
for _, tk := range tokens {
tokenByName[tk.TokenName] = tk
}
require.Contains(t, tokenByName, "token-one")
require.Contains(t, tokenByName, "scoped-token")
scopedToken := tokenByName["scoped-token"]
require.Contains(t, scopedToken.Scopes, codersdk.APIKeyScopeWorkspaceRead)
require.Len(t, scopedToken.AllowList, 1)
require.Equal(t, allowSpec, scopedToken.AllowList[0].String())
require.Len(t, tokens, 1)
require.Equal(t, id, tokens[0].ID)
// Delete by name
inv, root = clitest.New(t, "tokens", "rm", "token-one")
@@ -177,17 +135,6 @@ func TestTokens(t *testing.T) {
require.NotEmpty(t, res)
require.Contains(t, res, "deleted")
// Delete scoped token by ID
inv, root = clitest.New(t, "tokens", "rm", scopedTokenID)
clitest.SetupConfig(t, client, root)
buf = new(bytes.Buffer)
inv.Stdout = buf
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
res = buf.String()
require.NotEmpty(t, res)
require.Contains(t, res, "deleted")
// Create third token
inv, root = clitest.New(t, "tokens", "create", "--name", "token-three")
clitest.SetupConfig(t, client, root)
+3 -17
View File
@@ -11687,8 +11687,9 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
"initiator_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
@@ -11878,12 +11879,6 @@ const docTemplate = `{
"user_id"
],
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -13982,9 +13977,6 @@ const docTemplate = `{
"docs_url": {
"$ref": "#/definitions/serpent.URL"
},
"enable_authz_recording": {
"type": "boolean"
},
"enable_terraform_debug_mode": {
"type": "boolean"
},
@@ -15076,9 +15068,6 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
}
@@ -20903,9 +20892,6 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"role": {
"enum": [
"admin",
+3 -17
View File
@@ -10387,8 +10387,9 @@
"type": "string",
"format": "uuid"
},
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
"initiator_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
@@ -10578,12 +10579,6 @@
"user_id"
],
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -12600,9 +12595,6 @@
"docs_url": {
"$ref": "#/definitions/serpent.URL"
},
"enable_authz_recording": {
"type": "boolean"
},
"enable_terraform_debug_mode": {
"type": "boolean"
},
@@ -13638,9 +13630,6 @@
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
}
@@ -19219,9 +19208,6 @@
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"role": {
"enum": ["admin", "use"],
"allOf": [
-4
View File
@@ -102,10 +102,6 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
}
}
if len(params.AllowList) == 0 {
panic(fmt.Sprintf("developer error: API key %s has empty allow list", keyID))
}
token := fmt.Sprintf("%s-%s", keyID, keySecret)
return database.InsertAPIKeyParams{
-6
View File
@@ -51,8 +51,6 @@ func TestTokenCRUD(t *testing.T) {
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*6))
require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*8))
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)
require.Len(t, keys[0].AllowList, 1)
require.Equal(t, "*:*", keys[0].AllowList[0].String())
// no update
@@ -88,8 +86,6 @@ func TestTokenScoped(t *testing.T) {
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
require.Len(t, keys[0].AllowList, 1)
require.Equal(t, "*:*", keys[0].AllowList[0].String())
}
// Ensure backward-compat: when a token is created using the legacy singular
@@ -136,8 +132,6 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) {
require.Len(t, keys, 1)
require.Equal(t, tc.scope, keys[0].Scope)
require.ElementsMatch(t, keys[0].Scopes, tc.scopes)
require.Len(t, keys[0].AllowList, 1)
require.Equal(t, "*:*", keys[0].AllowList[0].String())
})
}
}
+1 -1
View File
@@ -493,7 +493,7 @@ func New(options *Options) *API {
// We add this middleware early, to make sure that authorization checks made
// by other middleware get recorded.
if buildinfo.IsDev() {
r.Use(httpmw.RecordAuthzChecks(options.DeploymentValues.EnableAuthzRecording.Value()))
r.Use(httpmw.RecordAuthzChecks)
}
ctx, cancel := context.WithCancel(context.Background())
+3 -19
View File
@@ -51,13 +51,6 @@ func ListLazy[F any, T any](convert func(F) T) func(list []F) []T {
}
}
func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget {
return codersdk.APIAllowListTarget{
Type: codersdk.RBACResource(entry.Type),
ID: entry.ID,
}
}
type ExternalAuthMeta struct {
Authenticated bool
ValidateError string
@@ -196,16 +189,6 @@ func MinimalUser(user database.User) codersdk.MinimalUser {
return codersdk.MinimalUser{
ID: user.ID,
Username: user.Username,
Name: user.Name,
AvatarURL: user.AvatarURL,
}
}
func MinimalUserFromVisibleUser(user database.VisibleUser) codersdk.MinimalUser {
return codersdk.MinimalUser{
ID: user.ID,
Username: user.Username,
Name: user.Name,
AvatarURL: user.AvatarURL,
}
}
@@ -214,6 +197,7 @@ func ReducedUser(user database.User) codersdk.ReducedUser {
return codersdk.ReducedUser{
MinimalUser: MinimalUser(user),
Email: user.Email,
Name: user.Name,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
LastSeenAt: user.LastSeenAt,
@@ -943,7 +927,7 @@ func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.Pr
}
}
func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
func AIBridgeInterception(interception database.AIBridgeInterception, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
sdkTokenUsages := List(tokenUsages, AIBridgeTokenUsage)
sort.Slice(sdkTokenUsages, func(i, j int) bool {
// created_at ASC
@@ -961,7 +945,7 @@ func AIBridgeInterception(interception database.AIBridgeInterception, initiator
})
return codersdk.AIBridgeInterception{
ID: interception.ID,
Initiator: MinimalUserFromVisibleUser(initiator),
InitiatorID: interception.InitiatorID,
Provider: interception.Provider,
Model: interception.Model,
Metadata: jsonOrEmptyMap(interception.Metadata),
+2 -2
View File
@@ -4461,7 +4461,7 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab
return q.db.InsertWorkspaceResourceMetadata(ctx, arg)
}
func (q *querier) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
func (q *querier) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.AIBridgeInterception, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
@@ -5870,7 +5870,7 @@ func (q *querier) CountAuthorizedConnectionLogs(ctx context.Context, arg databas
return q.CountConnectionLogs(ctx, arg)
}
func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, _ rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, _ rbac.PreparedAuthorized) ([]database.AIBridgeInterception, error) {
// TODO: Delete this function, all ListAIBridgeInterceptions should be authorized. For now just call ListAIBridgeInterceptions on the authz querier.
// This cannot be deleted for now because it's included in the
// database.Store interface, so dbauthz needs to implement it.
+2 -2
View File
@@ -4537,14 +4537,14 @@ func (s *MethodTestSuite) TestAIBridge() {
s.Run("ListAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeInterceptionsParams{}
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.AIBridgeInterception{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("ListAuthorizedAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeInterceptionsParams{}
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.AIBridgeInterception{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
+2 -2
View File
@@ -2714,7 +2714,7 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context,
return metadata, err
}
func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.AIBridgeInterception, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeInterceptions(ctx, arg)
m.queryLatencies.WithLabelValues("ListAIBridgeInterceptions").Observe(time.Since(start).Seconds())
@@ -3722,7 +3722,7 @@ func (m queryMetricsStore) CountAuthorizedConnectionLogs(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
func (m queryMetricsStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.AIBridgeInterception, error) {
start := time.Now()
r0, r1 := m.s.ListAuthorizedAIBridgeInterceptions(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeInterceptions").Observe(time.Since(start).Seconds())
+4 -4
View File
@@ -5804,10 +5804,10 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) *
}
// ListAIBridgeInterceptions mocks base method.
func (m *MockStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
func (m *MockStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.AIBridgeInterception, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAIBridgeInterceptions", ctx, arg)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsRow)
ret0, _ := ret[0].([]database.AIBridgeInterception)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -5864,10 +5864,10 @@ func (mr *MockStoreMockRecorder) ListAIBridgeUserPromptsByInterceptionIDs(ctx, i
}
// ListAuthorizedAIBridgeInterceptions mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
func (m *MockStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.AIBridgeInterception, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeInterceptions", ctx, arg, prepared)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsRow)
ret0, _ := ret[0].([]database.AIBridgeInterception)
ret1, _ := ret[1].(error)
return ret0, ret1
}
-3
View File
@@ -3511,9 +3511,6 @@ COMMENT ON TRIGGER workspace_agent_name_unique_trigger ON workspace_agents IS 'U
the uniqueness requirement. A trigger allows us to enforce uniqueness going
forward without requiring a migration to clean up historical data.';
ALTER TABLE ONLY aibridge_interceptions
ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ALTER TABLE ONLY api_keys
ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@@ -6,7 +6,6 @@ type ForeignKeyConstraint string
// ForeignKeyConstraint enums.
const (
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
@@ -1,49 +0,0 @@
-- We didn't add an FK as a premature optimization when the aibridge tables were
-- added, but for the initiator_id it's pretty annoying not having a strong
-- reference.
--
-- Since the aibridge feature is still in early access, we're going to add the
-- FK and drop any rows that violate it (which should be none). This isn't a
-- very efficient migration, but since the feature is behind an experimental
-- flag, it shouldn't have any impact on deployments that aren't using the
-- feature.
-- Step 1: Add FK without validating it
ALTER TABLE aibridge_interceptions
ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey
FOREIGN KEY (initiator_id)
REFERENCES users(id)
-- We can't:
-- - Cascade delete because this is an auditing feature, and it also
-- wouldn't delete related aibridge rows since we don't FK them.
-- - Set null because you can't correlate to the original user ID if the
-- user somehow gets deleted.
--
-- So we just use the default and don't do anything. This will result in a
-- deferred constraint violation error when the user is deleted.
--
-- In Coder, we don't delete user rows ever, so this should never happen
-- unless an admin manually deletes a user with SQL.
ON DELETE NO ACTION
-- Delay validation of existing data until after we've dropped rows that
-- violate the FK.
NOT VALID;
-- Step 2: Drop existing interceptions that violate the FK.
DELETE FROM aibridge_interceptions
WHERE initiator_id NOT IN (SELECT id FROM users);
-- Step 3: Drop existing rows from other tables that no longer have a valid
-- interception in the database.
DELETE FROM aibridge_token_usages
WHERE interception_id NOT IN (SELECT id FROM aibridge_interceptions);
DELETE FROM aibridge_user_prompts
WHERE interception_id NOT IN (SELECT id FROM aibridge_interceptions);
DELETE FROM aibridge_tool_usages
WHERE interception_id NOT IN (SELECT id FROM aibridge_interceptions);
-- Step 4: Validate the FK
ALTER TABLE aibridge_interceptions
VALIDATE CONSTRAINT aibridge_interceptions_initiator_id_fkey;
@@ -8,7 +8,7 @@ INSERT INTO
)
VALUES (
'be003e1e-b38f-43bf-847d-928074dd0aa8',
'30095c71-380b-457a-8995-97b8ee6e5307', -- admin@coder.com, from 000022_initial_v0.6.6.up.sql
'30095c71-380b-457a-8995-97b8ee6e5307',
'openai',
'gpt-5',
'2025-09-15 12:45:13.921148+00'
@@ -77,82 +77,3 @@ VALUES (
'{}',
'2025-09-15 12:45:21.674335+00'
);
-- For a later migration, we'll add an invalid interception without a valid
-- initiator_id.
INSERT INTO
aibridge_interceptions (
id,
initiator_id,
provider,
model,
started_at
)
VALUES (
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'cab8d56a-8922-4999-81a9-046b43ac1312', -- user does not exist
'openai',
'gpt-5',
'2025-09-15 12:45:13.921148+00'
);
INSERT INTO
aibridge_token_usages (
id,
interception_id,
provider_response_id,
input_tokens,
output_tokens,
metadata,
created_at
)
VALUES (
'5650db6c-0b7c-49e3-bb26-9b2ba0107e11',
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k',
10950,
118,
'{}',
'2025-09-15 12:45:21.674413+00'
);
INSERT INTO
aibridge_user_prompts (
id,
interception_id,
provider_response_id,
prompt,
metadata,
created_at
)
VALUES (
'1e76cb5b-7c34-4160-b604-a4256f856169',
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k',
'how many workspaces do i have',
'{}',
'2025-09-15 12:45:21.674335+00'
);
INSERT INTO
aibridge_tool_usages (
id,
interception_id,
provider_response_id,
tool,
server_url,
input,
injected,
invocation_error,
metadata,
created_at
)
VALUES (
'351b440f-d605-4f37-8ceb-011f0377b695',
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k',
'coder_list_workspaces',
'http://localhost:3000/api/experimental/mcp/http',
'{}',
true,
NULL,
'{}',
'2025-09-15 12:45:21.674413+00'
);
+10 -14
View File
@@ -763,11 +763,11 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
}
type aibridgeQuerier interface {
ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error)
ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]AIBridgeInterception, error)
CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error)
}
func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error) {
func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]AIBridgeInterception, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.AIBridgeInterceptionConverter(),
})
@@ -794,20 +794,16 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
return nil, err
}
defer rows.Close()
var items []ListAIBridgeInterceptionsRow
var items []AIBridgeInterception
for rows.Next() {
var i ListAIBridgeInterceptionsRow
var i AIBridgeInterception
if err := rows.Scan(
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
&i.VisibleUser.AvatarURL,
&i.ID,
&i.InitiatorID,
&i.Provider,
&i.Model,
&i.StartedAt,
&i.Metadata,
); err != nil {
return nil, err
}
+1 -1
View File
@@ -592,7 +592,7 @@ type sqlcQuerier interface {
InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error)
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, error)
ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]AIBridgeInterception, error)
ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error)
ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error)
ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeUserPrompt, error)
+10 -22
View File
@@ -528,12 +528,9 @@ func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIB
const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many
SELECT
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata,
visible_users.id, visible_users.username, visible_users.name, visible_users.avatar_url
id, initiator_id, provider, model, started_at, metadata
FROM
aibridge_interceptions
JOIN
visible_users ON visible_users.id = aibridge_interceptions.initiator_id
WHERE
-- Filter by time frame
CASE
@@ -595,12 +592,7 @@ type ListAIBridgeInterceptionsParams struct {
Limit int32 `db:"limit_" json:"limit_"`
}
type ListAIBridgeInterceptionsRow struct {
AIBridgeInterception AIBridgeInterception `db:"aibridge_interception" json:"aibridge_interception"`
VisibleUser VisibleUser `db:"visible_user" json:"visible_user"`
}
func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, error) {
func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]AIBridgeInterception, error) {
rows, err := q.db.QueryContext(ctx, listAIBridgeInterceptions,
arg.StartedAfter,
arg.StartedBefore,
@@ -615,20 +607,16 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
return nil, err
}
defer rows.Close()
var items []ListAIBridgeInterceptionsRow
var items []AIBridgeInterception
for rows.Next() {
var i ListAIBridgeInterceptionsRow
var i AIBridgeInterception
if err := rows.Scan(
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
&i.VisibleUser.AvatarURL,
&i.ID,
&i.InitiatorID,
&i.Provider,
&i.Model,
&i.StartedAt,
&i.Metadata,
); err != nil {
return nil, err
}
+1 -4
View File
@@ -111,12 +111,9 @@ WHERE
-- name: ListAIBridgeInterceptions :many
SELECT
sqlc.embed(aibridge_interceptions),
sqlc.embed(visible_users)
*
FROM
aibridge_interceptions
JOIN
visible_users ON visible_users.id = aibridge_interceptions.initiator_id
WHERE
-- Filter by time frame
CASE
+6 -17
View File
@@ -4,7 +4,6 @@ package httpmw
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
@@ -40,24 +39,14 @@ func AsAuthzSystem(mws ...func(http.Handler) http.Handler) func(http.Handler) ht
}
}
// RecordAuthzChecks enables recording all the authorization checks that
// RecordAuthzChecks enables recording all of the authorization checks that
// occurred in the processing of a request. This is mostly helpful for debugging
// and understanding what permissions are required for a given action.
//
// Can either be toggled on by a deployment wide configuration value, or opt-in on
// a per-request basis by setting the `x-record-authz-checks` header to a truthy value.
//
// Requires using a Recorder Authorizer.
//
//nolint:revive
func RecordAuthzChecks(always bool) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if enabled, _ := strconv.ParseBool(r.Header.Get("x-record-authz-checks")); enabled || always {
r = r.WithContext(rbac.WithAuthzCheckRecorder(r.Context()))
}
next.ServeHTTP(rw, r)
})
}
func RecordAuthzChecks(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
r = r.WithContext(rbac.WithAuthzCheckRecorder(r.Context()))
next.ServeHTTP(rw, r)
})
}
-1
View File
@@ -1958,7 +1958,6 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi
CreatedBy: codersdk.MinimalUser{
ID: version.CreatedBy,
Username: version.CreatedByUsername,
Name: version.CreatedByName,
AvatarURL: version.CreatedByAvatarURL,
},
Archived: version.Archived,
-6
View File
@@ -1596,11 +1596,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
scopes = append(scopes, codersdk.APIKeyScope(s))
}
allowList := db2sdk.List(k.AllowList, db2sdk.APIAllowListTarget)
if len(allowList) == 0 {
panic(fmt.Sprintf("developer error: API key %s has empty allow list", k.ID))
}
return codersdk.APIKey{
ID: k.ID,
UserID: k.UserID,
@@ -1613,6 +1608,5 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
Scopes: scopes,
LifetimeSeconds: k.LifetimeSeconds,
TokenName: k.TokenName,
AllowList: allowList,
}
}
+4 -1
View File
@@ -35,9 +35,12 @@ func Test_TimezoneIANA(t *testing.T) {
// This test can be flaky on some Windows runners :(
t.Skip("This test is flaky under Windows.")
}
_, found := os.LookupEnv("TZ")
oldEnv, found := os.LookupEnv("TZ")
if found {
require.NoError(t, os.Unsetenv("TZ"))
t.Cleanup(func() {
_ = os.Setenv("TZ", oldEnv)
})
}
zone, err := tz.TimezoneIANA()
+1 -1
View File
@@ -13,7 +13,7 @@ import (
type AIBridgeInterception struct {
ID uuid.UUID `json:"id" format:"uuid"`
Initiator MinimalUser `json:"initiator"`
InitiatorID uuid.UUID `json:"initiator_id" format:"uuid"`
Provider string `json:"provider"`
Model string `json:"model"`
Metadata map[string]any `json:"metadata"`
+11 -12
View File
@@ -12,18 +12,17 @@ import (
// APIKey: do not ever return the HashedSecret
type APIKey struct {
ID string `json:"id" validate:"required"`
UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"`
LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"`
ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"`
CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"`
Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead.
Scopes []APIKeyScope `json:"scopes"`
TokenName string `json:"token_name" validate:"required"`
LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"`
AllowList []APIAllowListTarget `json:"allow_list"`
ID string `json:"id" validate:"required"`
UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"`
LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"`
ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"`
CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"`
Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead.
Scopes []APIKeyScope `json:"scopes"`
TokenName string `json:"token_name" validate:"required"`
LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"`
}
// LoginType is the type of login used to create the API key.
-14
View File
@@ -487,7 +487,6 @@ type DeploymentValues struct {
Sessions SessionLifetime `json:"session_lifetime,omitempty" typescript:",notnull"`
DisablePasswordAuth serpent.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"`
Support SupportConfig `json:"support,omitempty" typescript:",notnull"`
EnableAuthzRecording serpent.Bool `json:"enable_authz_recording,omitempty" typescript:",notnull"`
ExternalAuthConfigs serpent.Struct[[]ExternalAuthConfig] `json:"external_auth,omitempty" typescript:",notnull"`
SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"`
WgtunnelHost serpent.String `json:"wgtunnel_host,omitempty" typescript:",notnull"`
@@ -3294,19 +3293,6 @@ Write out the current server config as YAML to stdout.`,
YAML: "key",
Hidden: true,
},
{
Name: "Enable Authorization Recordings",
Description: "All api requests will have a header including all authorization calls made during the request. " +
"This is used for debugging purposes and only available for dev builds.",
Required: false,
Flag: "enable-authz-recordings",
Env: "CODER_ENABLE_AUTHZ_RECORDINGS",
Default: "false",
Value: &c.EnableAuthzRecording,
// Do not show this option ever. It is a developer tool only, and not to be
// used externally.
Hidden: true,
},
}
return opts
+1 -1
View File
@@ -41,7 +41,6 @@ type UsersRequest struct {
type MinimalUser struct {
ID uuid.UUID `json:"id" validate:"required" table:"id" format:"uuid"`
Username string `json:"username" validate:"required" table:"username,default_sort"`
Name string `json:"name,omitempty" table:"name"`
AvatarURL string `json:"avatar_url,omitempty" format:"uri"`
}
@@ -51,6 +50,7 @@ type MinimalUser struct {
// required by the frontend.
type ReducedUser struct {
MinimalUser `table:"m,recursive_inline"`
Name string `json:"name,omitempty"`
Email string `json:"email" validate:"required" table:"email" format:"email"`
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" table:"updated at" format:"date-time"`
-5
View File
@@ -1793,11 +1793,6 @@
"description": "Delete a token",
"path": "reference/cli/tokens_remove.md"
},
{
"title": "tokens view",
"description": "Display detailed information about a token",
"path": "reference/cli/tokens_view.md"
},
{
"title": "unfavorite",
"description": "Remove a workspace from your favorites",
+1 -6
View File
@@ -31,12 +31,7 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/aibridge/intercepti
"results": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"metadata": {
"property1": null,
"property2": null
-1
View File
@@ -237,7 +237,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"scheme": "string",
"user": {}
},
"enable_authz_recording": true,
"enable_terraform_debug_mode": true,
"ephemeral_deployment": true,
"experiments": [
+16 -42
View File
@@ -436,12 +436,7 @@
```json
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"metadata": {
"property1": null,
"property2": null
@@ -501,7 +496,7 @@
| Name | Type | Required | Restrictions | Description |
|--------------------|---------------------------------------------------------------------|----------|--------------|-------------|
| `id` | string | false | | |
| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | |
| `initiator_id` | string | false | | |
| `metadata` | object | false | | |
| » `[any property]` | any | false | | |
| `model` | string | false | | |
@@ -518,12 +513,7 @@
"results": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"metadata": {
"property1": null,
"property2": null
@@ -742,12 +732,6 @@
```json
{
"allow_list": [
{
"id": "string",
"type": "*"
}
],
"created_at": "2019-08-24T14:15:22Z",
"expires_at": "2019-08-24T14:15:22Z",
"id": "string",
@@ -766,20 +750,19 @@
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|---------------------------------------------------------------------|----------|--------------|---------------------------------|
| `allow_list` | array of [codersdk.APIAllowListTarget](#codersdkapiallowlisttarget) | false | | |
| `created_at` | string | true | | |
| `expires_at` | string | true | | |
| `id` | string | true | | |
| `last_used` | string | true | | |
| `lifetime_seconds` | integer | true | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | |
| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. |
| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | |
| `token_name` | string | true | | |
| `updated_at` | string | true | | |
| `user_id` | string | true | | |
| Name | Type | Required | Restrictions | Description |
|--------------------|-------------------------------------------------------|----------|--------------|---------------------------------|
| `created_at` | string | true | | |
| `expires_at` | string | true | | |
| `id` | string | true | | |
| `last_used` | string | true | | |
| `lifetime_seconds` | integer | true | | |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | |
| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. |
| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | |
| `token_name` | string | true | | |
| `updated_at` | string | true | | |
| `user_id` | string | true | | |
#### Enumerated Values
@@ -2909,7 +2892,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"scheme": "string",
"user": {}
},
"enable_authz_recording": true,
"enable_terraform_debug_mode": true,
"ephemeral_deployment": true,
"experiments": [
@@ -3415,7 +3397,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"scheme": "string",
"user": {}
},
"enable_authz_recording": true,
"enable_terraform_debug_mode": true,
"ephemeral_deployment": true,
"experiments": [
@@ -3750,7 +3731,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `disable_password_auth` | boolean | false | | |
| `disable_path_apps` | boolean | false | | |
| `docs_url` | [serpent.URL](#serpenturl) | false | | |
| `enable_authz_recording` | boolean | false | | |
| `enable_terraform_debug_mode` | boolean | false | | |
| `ephemeral_deployment` | boolean | false | | |
| `experiments` | array of string | false | | |
@@ -4964,7 +4944,6 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
{
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
}
```
@@ -4975,7 +4954,6 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|--------------|--------|----------|--------------|-------------|
| `avatar_url` | string | false | | |
| `id` | string | true | | |
| `name` | string | false | | |
| `username` | string | true | | |
## codersdk.NotificationMethodsResponse
@@ -8577,7 +8555,6 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -10149,7 +10126,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
{
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"role": "admin",
"username": "string"
}
@@ -11790,7 +11766,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
{
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"role": "admin",
"username": "string"
}
@@ -11802,7 +11777,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|--------------|--------------------------------------------------|----------|--------------|-------------|
| `avatar_url` | string | false | | |
| `id` | string | true | | |
| `name` | string | false | | |
| `role` | [codersdk.WorkspaceRole](#codersdkworkspacerole) | false | | |
| `username` | string | true | | |
-9
View File
@@ -460,7 +460,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -562,7 +561,6 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -688,7 +686,6 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -1297,7 +1294,6 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions \
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -1378,7 +1374,6 @@ Status Code **200**
| `» created_by` | [codersdk.MinimalUser](schemas.md#codersdkminimaluser) | false | | |
| `»» avatar_url` | string(uri) | false | | |
| `»» id` | string(uuid) | true | | |
| `»» name` | string | false | | |
| `»» username` | string | true | | |
| `» has_external_agent` | boolean | false | | |
| `» id` | string(uuid) | false | | |
@@ -1584,7 +1579,6 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions/{templ
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -1665,7 +1659,6 @@ Status Code **200**
| `» created_by` | [codersdk.MinimalUser](schemas.md#codersdkminimaluser) | false | | |
| `»» avatar_url` | string(uri) | false | | |
| `»» id` | string(uuid) | true | | |
| `»» name` | string | false | | |
| `»» username` | string | true | | |
| `» has_external_agent` | boolean | false | | |
| `» id` | string(uuid) | false | | |
@@ -1761,7 +1754,6 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion} \
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
@@ -1872,7 +1864,6 @@ curl -X PATCH http://coder-server:8080/api/v2/templateversions/{templateversion}
"created_by": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"has_external_agent": true,
+22 -85
View File
@@ -757,12 +757,6 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \
```json
[
{
"allow_list": [
{
"id": "string",
"type": "*"
}
],
"created_at": "2019-08-24T14:15:22Z",
"expires_at": "2019-08-24T14:15:22Z",
"id": "string",
@@ -790,76 +784,31 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \
Status Code **200**
| Name | Type | Required | Restrictions | Description |
|----------------------|----------------------------------------------------------|----------|--------------|---------------------------------|
| `[array item]` | array | false | | |
| allow_list` | array | false | | |
| » id` | string | false | | |
| » type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | |
| created_at` | string(date-time) | true | | |
| expires_at` | string(date-time) | true | | |
| id` | string | true | | |
| last_used` | string(date-time) | true | | |
| lifetime_seconds` | integer | true | | |
| login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | |
| scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. |
| scopes` | array | false | | |
| `» token_name` | string | true | | |
| `» updated_at` | string(date-time) | true | | |
| `» user_id` | string(uuid) | true | | |
| Name | Type | Required | Restrictions | Description |
|----------------------|--------------------------------------------------------|----------|--------------|---------------------------------|
| `[array item]` | array | false | | |
| created_at` | string(date-time) | true | | |
| expires_at` | string(date-time) | true | | |
| id` | string | true | | |
| last_used` | string(date-time) | true | | |
| lifetime_seconds` | integer | true | | |
| login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | |
| scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. |
| scopes` | array | false | | |
| token_name` | string | true | | |
| updated_at` | string(date-time) | true | | |
| user_id` | string(uuid) | true | | |
#### Enumerated Values
| Property | Value |
|--------------|------------------------------------|
| `type` | `*` |
| `type` | `aibridge_interception` |
| `type` | `api_key` |
| `type` | `assign_org_role` |
| `type` | `assign_role` |
| `type` | `audit_log` |
| `type` | `connection_log` |
| `type` | `crypto_key` |
| `type` | `debug_info` |
| `type` | `deployment_config` |
| `type` | `deployment_stats` |
| `type` | `file` |
| `type` | `group` |
| `type` | `group_member` |
| `type` | `idpsync_settings` |
| `type` | `inbox_notification` |
| `type` | `license` |
| `type` | `notification_message` |
| `type` | `notification_preference` |
| `type` | `notification_template` |
| `type` | `oauth2_app` |
| `type` | `oauth2_app_code_token` |
| `type` | `oauth2_app_secret` |
| `type` | `organization` |
| `type` | `organization_member` |
| `type` | `prebuilt_workspace` |
| `type` | `provisioner_daemon` |
| `type` | `provisioner_jobs` |
| `type` | `replicas` |
| `type` | `system` |
| `type` | `tailnet_coordinator` |
| `type` | `task` |
| `type` | `template` |
| `type` | `usage_event` |
| `type` | `user` |
| `type` | `user_secret` |
| `type` | `webpush_subscription` |
| `type` | `workspace` |
| `type` | `workspace_agent_devcontainers` |
| `type` | `workspace_agent_resource_monitor` |
| `type` | `workspace_dormant` |
| `type` | `workspace_proxy` |
| `login_type` | `password` |
| `login_type` | `github` |
| `login_type` | `oidc` |
| `login_type` | `token` |
| `scope` | `all` |
| `scope` | `application_connect` |
| Property | Value |
|--------------|-----------------------|
| `login_type` | `password` |
| `login_type` | `github` |
| `login_type` | `oidc` |
| `login_type` | `token` |
| `scope` | `all` |
| `scope` | `application_connect` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -947,12 +896,6 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \
```json
{
"allow_list": [
{
"id": "string",
"type": "*"
}
],
"created_at": "2019-08-24T14:15:22Z",
"expires_at": "2019-08-24T14:15:22Z",
"id": "string",
@@ -1003,12 +946,6 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \
```json
{
"allow_list": [
{
"id": "string",
"type": "*"
}
],
"created_at": "2019-08-24T14:15:22Z",
"expires_at": "2019-08-24T14:15:22Z",
"id": "string",
-1
View File
@@ -1588,7 +1588,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/acl \
{
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"role": "admin",
"username": "string"
}
+5 -10
View File
@@ -25,10 +25,6 @@ Tokens are used to authenticate automated clients to Coder.
$ coder tokens ls
- Create a scoped token:
$ coder tokens create --scope workspace:read --allow workspace:<uuid>
- Remove a token by ID:
$ coder tokens rm WuoWs4ZsMX
@@ -36,9 +32,8 @@ Tokens are used to authenticate automated clients to Coder.
## Subcommands
| Name | Purpose |
|-------------------------------------------|--------------------------------------------|
| [<code>create</code>](./tokens_create.md) | Create a token |
| [<code>list</code>](./tokens_list.md) | List tokens |
| [<code>view</code>](./tokens_view.md) | Display detailed information about a token |
| [<code>remove</code>](./tokens_remove.md) | Delete a token |
| Name | Purpose |
|-------------------------------------------|----------------|
| [<code>create</code>](./tokens_create.md) | Create a token |
| [<code>list</code>](./tokens_list.md) | List tokens |
| [<code>remove</code>](./tokens_remove.md) | Delete a token |
-16
View File
@@ -37,19 +37,3 @@ Specify a human-readable name.
| Environment | <code>$CODER_TOKEN_USER</code> |
Specify the user to create the token for (Only works if logged in user is admin).
### --scope
| | |
|------|--------------------|
| Type | <code>scope</code> |
Repeatable scope to attach to the token (e.g. workspace:read).
### --allow
| | |
|------|------------------------|
| Type | <code>allowList</code> |
Repeatable allow-list entry (<type>:<uuid>, e.g. workspace:1234-...).
+4 -4
View File
@@ -25,10 +25,10 @@ Specifies whether all users' tokens will be listed or not (must have Owner role
### -c, --column
| | |
|---------|---------------------------------------------------------------------------------------|
| Type | <code>[id\|name\|scopes\|allow list\|last used\|expires at\|created at\|owner]</code> |
| Default | <code>id,name,scopes,allow list,last used,expires at,created at</code> |
| | |
|---------|-------------------------------------------------------------------|
| Type | <code>[id\|name\|last used\|expires at\|created at\|owner]</code> |
| Default | <code>id,name,last used,expires at,created at</code> |
Columns to display in table output.
-30
View File
@@ -1,30 +0,0 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# tokens view
Display detailed information about a token
## Usage
```console
coder tokens view [flags] <name|id>
```
## Options
### -c, --column
| | |
|---------|---------------------------------------------------------------------------------------|
| Type | <code>[id\|name\|scopes\|allow list\|last used\|expires at\|created at\|owner]</code> |
| Default | <code>id,name,scopes,allow list,last used,expires at,created at,owner</code> |
Columns to display in table output.
### -o, --output
| | |
|---------|--------------------------|
| Type | <code>table\|json</code> |
| Default | <code>table</code> |
Output format.
+4 -4
View File
@@ -25,10 +25,10 @@ Filter users by their GitHub user ID.
### -c, --column
| | |
|---------|--------------------------------------------------------------------------|
| Type | <code>[id\|username\|name\|email\|created at\|updated at\|status]</code> |
| Default | <code>username,email,created at,status</code> |
| | |
|---------|--------------------------------------------------------------------|
| Type | <code>[id\|username\|email\|created at\|updated at\|status]</code> |
| Default | <code>username,email,created at,status</code> |
Columns to display in table output.
+4 -10
View File
@@ -75,7 +75,7 @@ func (api *API) aiBridgeListInterceptions(rw http.ResponseWriter, r *http.Reques
var (
count int64
rows []database.ListAIBridgeInterceptionsRow
rows []database.AIBridgeInterception
)
err := api.Database.InTx(func(db database.Store) error {
// Ensure the after_id interception exists and is visible to the user.
@@ -132,10 +132,10 @@ func (api *API) aiBridgeListInterceptions(rw http.ResponseWriter, r *http.Reques
})
}
func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.Store, dbInterceptions []database.ListAIBridgeInterceptionsRow) ([]codersdk.AIBridgeInterception, error) {
func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.Store, dbInterceptions []database.AIBridgeInterception) ([]codersdk.AIBridgeInterception, error) {
ids := make([]uuid.UUID, len(dbInterceptions))
for i, row := range dbInterceptions {
ids[i] = row.AIBridgeInterception.ID
ids[i] = row.ID
}
//nolint:gocritic // This is a system function until we implement a join for aibridge interceptions. AIBridge interception subresources use the same authorization call as their parent.
@@ -170,13 +170,7 @@ func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.S
items := make([]codersdk.AIBridgeInterception, len(dbInterceptions))
for i, row := range dbInterceptions {
items[i] = db2sdk.AIBridgeInterception(
row.AIBridgeInterception,
row.VisibleUser,
tokenUsagesMap[row.AIBridgeInterception.ID],
userPromptsMap[row.AIBridgeInterception.ID],
toolUsagesMap[row.AIBridgeInterception.ID],
)
items[i] = db2sdk.AIBridgeInterception(row, tokenUsagesMap[row.ID], userPromptsMap[row.ID], toolUsagesMap[row.ID])
}
return items, nil
+19 -57
View File
@@ -72,7 +72,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentAIBridge)}
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
@@ -85,28 +85,10 @@ func TestAIBridgeListInterceptions(t *testing.T) {
experimentalClient := codersdk.NewExperimentalClient(client)
ctx := testutil.Context(t, testutil.WaitLong)
user1, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
user1Visible := database.VisibleUser{
ID: user1.ID,
Username: user1.Username,
Name: user1.Name,
AvatarURL: user1.AvatarURL,
}
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
user2Visible := database.VisibleUser{
ID: user2.ID,
Username: user2.Username,
Name: user2.Name,
AvatarURL: user2.AvatarURL,
}
// Insert a bunch of test data.
now := dbtime.Now()
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: user1.ID,
StartedAt: now.Add(-time.Hour),
StartedAt: now.Add(-time.Hour),
})
i1tok1 := dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
InterceptionID: i1.ID,
@@ -133,15 +115,14 @@ func TestAIBridgeListInterceptions(t *testing.T) {
CreatedAt: now.Add(-time.Minute),
})
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: user2.ID,
StartedAt: now,
StartedAt: now,
})
// Convert to SDK types for response comparison.
// You may notice that the ordering of the inner arrays are ASC, this is
// intentional.
i1SDK := db2sdk.AIBridgeInterception(i1, user1Visible, []database.AIBridgeTokenUsage{i1tok2, i1tok1}, []database.AIBridgeUserPrompt{i1up2, i1up1}, []database.AIBridgeToolUsage{i1tool2, i1tool1})
i2SDK := db2sdk.AIBridgeInterception(i2, user2Visible, nil, nil, nil)
i1SDK := db2sdk.AIBridgeInterception(i1, []database.AIBridgeTokenUsage{i1tok2, i1tok1}, []database.AIBridgeUserPrompt{i1up2, i1up1}, []database.AIBridgeToolUsage{i1tool2, i1tool1})
i2SDK := db2sdk.AIBridgeInterception(i2, nil, nil, nil)
res, err := experimentalClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
require.NoError(t, err)
@@ -177,7 +158,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentAIBridge)}
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
client, db, _ := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
@@ -197,9 +178,8 @@ func TestAIBridgeListInterceptions(t *testing.T) {
now := dbtime.Now()
for i := range 10 {
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.UUID{byte(i)},
InitiatorID: firstUser.UserID,
StartedAt: now,
ID: uuid.UUID{byte(i)},
StartedAt: now,
})
allInterceptionIDs = append(allInterceptionIDs, interception.ID)
}
@@ -210,9 +190,8 @@ func TestAIBridgeListInterceptions(t *testing.T) {
require.NoError(t, err)
randomOffsetDur := time.Duration(randomOffset) * time.Second
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.UUID{byte(i + 10)},
InitiatorID: firstUser.UserID,
StartedAt: now.Add(randomOffsetDur),
ID: uuid.UUID{byte(i + 10)},
StartedAt: now.Add(randomOffsetDur),
})
allInterceptionIDs = append(allInterceptionIDs, interception.ID)
}
@@ -350,44 +329,27 @@ func TestAIBridgeListInterceptions(t *testing.T) {
},
})
experimentalClient := codersdk.NewExperimentalClient(client)
ctx := testutil.Context(t, testutil.WaitLong)
user1, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
user1Visible := database.VisibleUser{
ID: user1.ID,
Username: user1.Username,
Name: user1.Name,
AvatarURL: user1.AvatarURL,
}
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
user2Visible := database.VisibleUser{
ID: user2.ID,
Username: user2.Username,
Name: user2.Name,
AvatarURL: user2.AvatarURL,
}
_, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
// Insert a bunch of test data with varying filterable fields.
now := dbtime.Now()
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
InitiatorID: user1.ID,
InitiatorID: firstUser.UserID,
Provider: "one",
Model: "one",
StartedAt: now,
})
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
InitiatorID: user1.ID,
InitiatorID: firstUser.UserID,
Provider: "two",
Model: "two",
StartedAt: now.Add(-time.Hour),
})
i3 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000003"),
InitiatorID: user2.ID,
InitiatorID: secondUser.ID,
Provider: "three",
Model: "three",
StartedAt: now.Add(-2 * time.Hour),
@@ -395,9 +357,9 @@ func TestAIBridgeListInterceptions(t *testing.T) {
// Convert to SDK types for response comparison. We don't care about the
// inner arrays for this test.
i1SDK := db2sdk.AIBridgeInterception(i1, user1Visible, nil, nil, nil)
i2SDK := db2sdk.AIBridgeInterception(i2, user1Visible, nil, nil, nil)
i3SDK := db2sdk.AIBridgeInterception(i3, user2Visible, nil, nil, nil)
i1SDK := db2sdk.AIBridgeInterception(i1, nil, nil, nil)
i2SDK := db2sdk.AIBridgeInterception(i2, nil, nil, nil)
i3SDK := db2sdk.AIBridgeInterception(i3, nil, nil, nil)
cases := []struct {
name string
@@ -421,12 +383,12 @@ func TestAIBridgeListInterceptions(t *testing.T) {
},
{
name: "Initiator/UserID",
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: user2.ID.String()},
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: secondUser.ID.String()},
want: []codersdk.AIBridgeInterception{i3SDK},
},
{
name: "Initiator/Username",
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: user2.Username},
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: secondUser.Username},
want: []codersdk.AIBridgeInterception{i3SDK},
},
{
+3 -5
View File
@@ -24,8 +24,9 @@ type Config struct {
// DialTimeout is how long to wait for websocket connection.
DialTimeout time.Duration `json:"dial_timeout"`
// ExpectedNotificationsIDs is the list of notification template IDs to expect.
ExpectedNotificationsIDs map[uuid.UUID]struct{} `json:"-"`
// ExpectedNotifications maps notification template IDs to channels
// that receive the trigger time for each notification.
ExpectedNotifications map[uuid.UUID]chan time.Time `json:"-"`
Metrics *Metrics `json:"-"`
@@ -34,9 +35,6 @@ type Config struct {
// ReceivingWatchBarrier is the barrier for receiving users. Regular users wait on this to disconnect after receiving users complete.
ReceivingWatchBarrier *sync.WaitGroup `json:"-"`
// SMTPApiUrl is the URL of the SMTP mock HTTP API
SMTPApiURL string `json:"smtp_api_url"`
}
func (c Config) Validate() error {
+19 -14
View File
@@ -6,16 +6,10 @@ import (
"github.com/prometheus/client_golang/prometheus"
)
type NotificationType string
const (
NotificationTypeWebsocket NotificationType = "websocket"
NotificationTypeSMTP NotificationType = "smtp"
)
type Metrics struct {
notificationLatency *prometheus.HistogramVec
notificationErrors *prometheus.CounterVec
missedNotifications *prometheus.CounterVec
}
func NewMetrics(reg prometheus.Registerer) *Metrics {
@@ -28,26 +22,37 @@ func NewMetrics(reg prometheus.Registerer) *Metrics {
Subsystem: "scaletest",
Name: "notification_delivery_latency_seconds",
Help: "Time between notification-creating action and receipt of notification by client",
}, []string{"notification_id", "notification_type"})
}, []string{"username", "notification_type"})
errors := prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "scaletest",
Name: "notification_delivery_errors_total",
Help: "Total number of notification delivery errors",
}, []string{"action"})
}, []string{"username", "action"})
missed := prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "scaletest",
Name: "notification_delivery_missed_total",
Help: "Total number of missed notifications",
}, []string{"username"})
reg.MustRegister(latency, errors)
reg.MustRegister(latency, errors, missed)
return &Metrics{
notificationLatency: latency,
notificationErrors: errors,
missedNotifications: missed,
}
}
func (m *Metrics) RecordLatency(latency time.Duration, notificationID string, notificationType NotificationType) {
m.notificationLatency.WithLabelValues(notificationID, string(notificationType)).Observe(latency.Seconds())
func (m *Metrics) RecordLatency(latency time.Duration, username, notificationType string) {
m.notificationLatency.WithLabelValues(username, notificationType).Observe(latency.Seconds())
}
func (m *Metrics) AddError(action string) {
m.notificationErrors.WithLabelValues(action).Inc()
func (m *Metrics) AddError(username, action string) {
m.notificationErrors.WithLabelValues(username, action).Inc()
}
func (m *Metrics) RecordMissed(username string) {
m.missedNotifications.WithLabelValues(username).Inc()
}
+31 -162
View File
@@ -3,16 +3,12 @@ package notifications
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"cdr.dev/slog"
@@ -23,8 +19,6 @@ import (
"github.com/coder/coder/v2/scaletest/createusers"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/coder/v2/scaletest/smtpmock"
"github.com/coder/quartz"
"github.com/coder/websocket"
)
@@ -34,32 +28,18 @@ type Runner struct {
createUserRunner *createusers.Runner
// websocketReceiptTimes stores the receipt time for websocket notifications
websocketReceiptTimes map[uuid.UUID]time.Time
websocketReceiptTimesMu sync.RWMutex
// smtpReceiptTimes stores the receipt time for SMTP notifications
smtpReceiptTimes map[uuid.UUID]time.Time
smtpReceiptTimesMu sync.RWMutex
clock quartz.Clock
// notificationLatencies stores the latency for each notification type
notificationLatencies map[uuid.UUID]time.Duration
}
func NewRunner(client *codersdk.Client, cfg Config) *Runner {
return &Runner{
client: client,
cfg: cfg,
websocketReceiptTimes: make(map[uuid.UUID]time.Time),
smtpReceiptTimes: make(map[uuid.UUID]time.Time),
clock: quartz.NewReal(),
notificationLatencies: make(map[uuid.UUID]time.Duration),
}
}
func (r *Runner) WithClock(clock quartz.Clock) *Runner {
r.clock = clock
return r
}
var (
_ harness.Runnable = &Runner{}
_ harness.Cleanable = &Runner{}
@@ -79,7 +59,7 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
reachedReceivingWatchBarrier := false
defer func() {
if len(r.cfg.ExpectedNotificationsIDs) > 0 && !reachedReceivingWatchBarrier {
if len(r.cfg.ExpectedNotifications) > 0 && !reachedReceivingWatchBarrier {
r.cfg.ReceivingWatchBarrier.Done()
}
}()
@@ -92,7 +72,7 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
r.createUserRunner = createusers.NewRunner(r.client, r.cfg.User)
newUserAndToken, err := r.createUserRunner.RunReturningUser(ctx, id, logs)
if err != nil {
r.cfg.Metrics.AddError("create_user")
r.cfg.Metrics.AddError("", "create_user")
return xerrors.Errorf("create user: %w", err)
}
newUser := newUserAndToken.User
@@ -110,7 +90,7 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
Roles: r.cfg.Roles,
})
if err != nil {
r.cfg.Metrics.AddError("assign_roles")
r.cfg.Metrics.AddError(newUser.Username, "assign_roles")
return xerrors.Errorf("assign roles: %w", err)
}
}
@@ -121,7 +101,7 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
defer cancel()
logger.Info(ctx, "connecting to notification websocket")
conn, err := r.dialNotificationWebsocket(dialCtx, newUserClient, logger)
conn, err := r.dialNotificationWebsocket(dialCtx, newUserClient, newUser, logger)
if err != nil {
return xerrors.Errorf("dial notification websocket: %w", err)
}
@@ -132,7 +112,7 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
r.cfg.DialBarrier.Done()
r.cfg.DialBarrier.Wait()
if len(r.cfg.ExpectedNotificationsIDs) == 0 {
if len(r.cfg.ExpectedNotifications) == 0 {
logger.Info(ctx, "maintaining websocket connection, waiting for receiving users to complete")
// Wait for receiving users to complete
@@ -156,20 +136,7 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error {
watchCtx, cancel := context.WithTimeout(ctx, r.cfg.NotificationTimeout)
defer cancel()
eg, egCtx := errgroup.WithContext(watchCtx)
eg.Go(func() error {
return r.watchNotifications(egCtx, conn, newUser, logger, r.cfg.ExpectedNotificationsIDs)
})
if r.cfg.SMTPApiURL != "" {
logger.Info(ctx, "running SMTP notification watcher")
eg.Go(func() error {
return r.watchNotificationsSMTP(egCtx, newUser, logger, r.cfg.ExpectedNotificationsIDs)
})
}
if err := eg.Wait(); err != nil {
if err := r.watchNotifications(watchCtx, conn, newUser, logger, r.cfg.ExpectedNotifications); err != nil {
return xerrors.Errorf("notification watch failed: %w", err)
}
@@ -190,31 +157,19 @@ func (r *Runner) Cleanup(ctx context.Context, id string, logs io.Writer) error {
return nil
}
const (
WebsocketNotificationReceiptTimeMetric = "notification_websocket_receipt_time"
SMTPNotificationReceiptTimeMetric = "notification_smtp_receipt_time"
)
const NotificationDeliveryLatencyMetric = "notification_delivery_latency_seconds"
func (r *Runner) GetMetrics() map[string]any {
r.websocketReceiptTimesMu.RLock()
websocketReceiptTimes := maps.Clone(r.websocketReceiptTimes)
r.websocketReceiptTimesMu.RUnlock()
r.smtpReceiptTimesMu.RLock()
smtpReceiptTimes := maps.Clone(r.smtpReceiptTimes)
r.smtpReceiptTimesMu.RUnlock()
return map[string]any{
WebsocketNotificationReceiptTimeMetric: websocketReceiptTimes,
SMTPNotificationReceiptTimeMetric: smtpReceiptTimes,
NotificationDeliveryLatencyMetric: r.notificationLatencies,
}
}
func (r *Runner) dialNotificationWebsocket(ctx context.Context, client *codersdk.Client, logger slog.Logger) (*websocket.Conn, error) {
func (r *Runner) dialNotificationWebsocket(ctx context.Context, client *codersdk.Client, user codersdk.User, logger slog.Logger) (*websocket.Conn, error) {
u, err := client.URL.Parse("/api/v2/notifications/inbox/watch")
if err != nil {
logger.Error(ctx, "parse notification URL", slog.Error(err))
r.cfg.Metrics.AddError("parse_url")
r.cfg.Metrics.AddError(user.Username, "parse_url")
return nil, xerrors.Errorf("parse notification URL: %w", err)
}
@@ -231,7 +186,7 @@ func (r *Runner) dialNotificationWebsocket(ctx context.Context, client *codersdk
}
}
logger.Error(ctx, "dial notification websocket", slog.Error(err))
r.cfg.Metrics.AddError("dial")
r.cfg.Metrics.AddError(user.Username, "dial")
return nil, xerrors.Errorf("dial notification websocket: %w", err)
}
@@ -240,7 +195,7 @@ func (r *Runner) dialNotificationWebsocket(ctx context.Context, client *codersdk
// watchNotifications reads notifications from the websocket and returns error or nil
// once all expected notifications are received.
func (r *Runner) watchNotifications(ctx context.Context, conn *websocket.Conn, user codersdk.User, logger slog.Logger, expectedNotifications map[uuid.UUID]struct{}) error {
func (r *Runner) watchNotifications(ctx context.Context, conn *websocket.Conn, user codersdk.User, logger slog.Logger, expectedNotifications map[uuid.UUID]chan time.Time) error {
logger.Info(ctx, "waiting for notifications",
slog.F("username", user.Username),
slog.F("expected_count", len(expectedNotifications)))
@@ -262,23 +217,28 @@ func (r *Runner) watchNotifications(ctx context.Context, conn *websocket.Conn, u
notif, err := readNotification(ctx, conn)
if err != nil {
logger.Error(ctx, "read notification", slog.Error(err))
r.cfg.Metrics.AddError("read_notification_websocket")
r.cfg.Metrics.AddError(user.Username, "read_notification")
return xerrors.Errorf("read notification: %w", err)
}
templateID := notif.Notification.TemplateID
if _, exists := expectedNotifications[templateID]; exists {
if _, received := receivedNotifications[templateID]; !received {
if triggerTimeChan, exists := expectedNotifications[templateID]; exists {
if _, exists := receivedNotifications[templateID]; !exists {
receiptTime := time.Now()
r.websocketReceiptTimesMu.Lock()
r.websocketReceiptTimes[templateID] = receiptTime
r.websocketReceiptTimesMu.Unlock()
receivedNotifications[templateID] = struct{}{}
select {
case triggerTime := <-triggerTimeChan:
latency := receiptTime.Sub(triggerTime)
r.notificationLatencies[templateID] = latency
r.cfg.Metrics.RecordLatency(latency, user.Username, templateID.String())
receivedNotifications[templateID] = struct{}{}
logger.Info(ctx, "received expected notification",
slog.F("template_id", templateID),
slog.F("title", notif.Notification.Title),
slog.F("receipt_time", receiptTime))
logger.Info(ctx, "received expected notification",
slog.F("template_id", templateID),
slog.F("title", notif.Notification.Title),
slog.F("latency", latency))
case <-ctx.Done():
return xerrors.Errorf("context canceled while waiting for trigger time: %w", ctx.Err())
}
}
} else {
logger.Debug(ctx, "received notification not being tested",
@@ -288,97 +248,6 @@ func (r *Runner) watchNotifications(ctx context.Context, conn *websocket.Conn, u
}
}
// watchNotificationsSMTP polls the SMTP HTTP API for notifications and returns error or nil
// once all expected notifications are received.
func (r *Runner) watchNotificationsSMTP(ctx context.Context, user codersdk.User, logger slog.Logger, expectedNotifications map[uuid.UUID]struct{}) error {
logger.Info(ctx, "polling SMTP API for notifications",
slog.F("email", user.Email),
slog.F("expected_count", len(expectedNotifications)),
)
receivedNotifications := make(map[uuid.UUID]struct{})
apiURL := fmt.Sprintf("%s/messages?email=%s", r.cfg.SMTPApiURL, user.Email)
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
const smtpPollInterval = 2 * time.Second
done := xerrors.New("done")
tkr := r.clock.TickerFunc(ctx, smtpPollInterval, func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
logger.Error(ctx, "create SMTP API request", slog.Error(err))
r.cfg.Metrics.AddError("smtp_create_request")
return xerrors.Errorf("create SMTP API request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
logger.Error(ctx, "poll smtp api for notifications", slog.Error(err))
r.cfg.Metrics.AddError("smtp_poll")
return xerrors.Errorf("poll smtp api: %w", err)
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
logger.Error(ctx, "smtp api returned non-200 status", slog.F("status", resp.StatusCode))
r.cfg.Metrics.AddError("smtp_bad_status")
return xerrors.Errorf("smtp api returned status %d", resp.StatusCode)
}
var summaries []smtpmock.EmailSummary
if err := json.NewDecoder(resp.Body).Decode(&summaries); err != nil {
_ = resp.Body.Close()
logger.Error(ctx, "decode smtp api response", slog.Error(err))
r.cfg.Metrics.AddError("smtp_decode")
return xerrors.Errorf("decode smtp api response: %w", err)
}
_ = resp.Body.Close()
// Process each email summary
for _, summary := range summaries {
notificationID := summary.NotificationTemplateID
if notificationID == uuid.Nil {
continue
}
if _, exists := expectedNotifications[notificationID]; exists {
if _, received := receivedNotifications[notificationID]; !received {
receiptTime := summary.Date
if receiptTime.IsZero() {
receiptTime = time.Now()
}
r.smtpReceiptTimesMu.Lock()
r.smtpReceiptTimes[notificationID] = receiptTime
r.smtpReceiptTimesMu.Unlock()
receivedNotifications[notificationID] = struct{}{}
logger.Info(ctx, "received expected notification via SMTP",
slog.F("notification_id", notificationID),
slog.F("subject", summary.Subject),
slog.F("receipt_time", receiptTime))
}
}
}
if len(receivedNotifications) == len(expectedNotifications) {
logger.Info(ctx, "received all expected notifications via SMTP")
return done
}
return nil
}, "smtp")
err := tkr.Wait()
if errors.Is(err, done) {
return nil
}
return err
}
func readNotification(ctx context.Context, conn *websocket.Conn) (codersdk.GetInboxNotificationResponse, error) {
_, message, err := conn.Read(ctx)
if err != nil {
+89 -211
View File
@@ -1,11 +1,7 @@
package notifications_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
@@ -13,19 +9,22 @@ import (
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/serpent"
"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/dbtestutil"
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scaletest/createusers"
"github.com/coder/coder/v2/scaletest/notifications"
"github.com/coder/coder/v2/scaletest/smtpmock"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
@@ -37,11 +36,39 @@ func TestRun(t *testing.T) {
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
inboxHandler := dispatch.NewInboxHandler(logger.Named("inbox"), db, ps)
// Setup notifications manager with inbox handler
cfg := defaultNotificationsConfig(database.NotificationMethodSmtp)
mgr, err := notificationsLib.NewManager(
cfg,
db,
ps,
defaultHelpers(),
notificationsLib.NewMetrics(prometheus.NewRegistry()),
logger.Named("manager"),
)
require.NoError(t, err)
mgr.WithHandlers(map[database.NotificationMethod]notificationsLib.Handler{
database.NotificationMethodInbox: dispatch.NewInboxHandler(logger.Named("inbox"), db, ps),
})
t.Cleanup(func() {
assert.NoError(t, mgr.Stop(dbauthz.AsNotifier(ctx)))
})
mgr.Run(dbauthz.AsNotifier(ctx))
enqueuer, err := notificationsLib.NewStoreEnqueuer(
cfg,
db,
defaultHelpers(),
logger.Named("enqueuer"),
quartz.NewReal(),
)
require.NoError(t, err)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Database: db,
Pubsub: ps,
NotificationsEnqueuer: enqueuer,
})
firstUser := coderdtest.CreateFirstUser(t, client)
@@ -55,9 +82,9 @@ func TestRun(t *testing.T) {
eg, runCtx := errgroup.WithContext(ctx)
expectedNotificationsIDs := map[uuid.UUID]struct{}{
notificationsLib.TemplateUserAccountCreated: {},
notificationsLib.TemplateUserAccountDeleted: {},
expectedNotifications := map[uuid.UUID]chan time.Time{
notificationsLib.TemplateUserAccountCreated: make(chan time.Time, 1),
notificationsLib.TemplateUserAccountDeleted: make(chan time.Time, 1),
}
// Start receiving runners who will receive notifications
@@ -66,15 +93,14 @@ func TestRun(t *testing.T) {
runnerCfg := notifications.Config{
User: createusers.Config{
OrganizationID: firstUser.OrganizationID,
Username: "receiving-user-" + strconv.Itoa(i),
},
Roles: []string{codersdk.RoleOwner},
NotificationTimeout: testutil.WaitLong,
DialTimeout: testutil.WaitLong,
Metrics: metrics,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: receivingWatchBarrier,
ExpectedNotificationsIDs: expectedNotificationsIDs,
Roles: []string{codersdk.RoleOwner},
NotificationTimeout: testutil.WaitLong,
DialTimeout: testutil.WaitLong,
Metrics: metrics,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: receivingWatchBarrier,
ExpectedNotifications: expectedNotifications,
}
err := runnerCfg.Validate()
require.NoError(t, err)
@@ -115,17 +141,31 @@ func TestRun(t *testing.T) {
// Wait for all runners to connect
dialBarrier.Wait()
for i := 0; i < numReceivingUsers; i++ {
err := sendInboxNotification(runCtx, t, db, inboxHandler, "receiving-user-"+strconv.Itoa(i), notificationsLib.TemplateUserAccountCreated)
require.NoError(t, err)
err = sendInboxNotification(runCtx, t, db, inboxHandler, "receiving-user-"+strconv.Itoa(i), notificationsLib.TemplateUserAccountDeleted)
require.NoError(t, err)
createTime := time.Now()
newUser, err := client.CreateUserWithOrgs(runCtx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{firstUser.OrganizationID},
Email: "test-user@coder.com",
Username: "test-user",
Password: "SomeSecurePassword!",
})
if err != nil {
return xerrors.Errorf("create test user: %w", err)
}
expectedNotifications[notificationsLib.TemplateUserAccountCreated] <- createTime
deleteTime := time.Now()
if err := client.DeleteUser(runCtx, newUser.ID); err != nil {
return xerrors.Errorf("delete test user: %w", err)
}
expectedNotifications[notificationsLib.TemplateUserAccountDeleted] <- deleteTime
close(expectedNotifications[notificationsLib.TemplateUserAccountCreated])
close(expectedNotifications[notificationsLib.TemplateUserAccountDeleted])
return nil
})
err := eg.Wait()
err = eg.Wait()
require.NoError(t, err, "runner execution should complete successfully")
cleanupEg, cleanupCtx := errgroup.WithContext(ctx)
@@ -148,196 +188,34 @@ func TestRun(t *testing.T) {
require.Equal(t, firstUser.UserID, users.Users[0].ID)
for _, runner := range receivingRunners {
metrics := runner.GetMetrics()
websocketReceiptTimes := metrics[notifications.WebsocketNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time)
require.Contains(t, websocketReceiptTimes, notificationsLib.TemplateUserAccountCreated)
require.Contains(t, websocketReceiptTimes, notificationsLib.TemplateUserAccountDeleted)
runnerMetrics := runner.GetMetrics()[notifications.NotificationDeliveryLatencyMetric].(map[uuid.UUID]time.Duration)
require.Contains(t, runnerMetrics, notificationsLib.TemplateUserAccountCreated)
require.Contains(t, runnerMetrics, notificationsLib.TemplateUserAccountDeleted)
}
}
func TestRunWithSMTP(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
inboxHandler := dispatch.NewInboxHandler(logger.Named("inbox"), db, ps)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
})
firstUser := coderdtest.CreateFirstUser(t, client)
smtpAPIMux := http.NewServeMux()
smtpAPIMux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
summaries := []smtpmock.EmailSummary{
{
Subject: "TemplateUserAccountCreated",
Date: time.Now(),
NotificationTemplateID: notificationsLib.TemplateUserAccountCreated,
},
{
Subject: "TemplateUserAccountDeleted",
Date: time.Now(),
NotificationTemplateID: notificationsLib.TemplateUserAccountDeleted,
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(summaries)
})
smtpAPIServer := httptest.NewServer(smtpAPIMux)
defer smtpAPIServer.Close()
const numReceivingUsers = 2
const numRegularUsers = 2
dialBarrier := new(sync.WaitGroup)
receivingWatchBarrier := new(sync.WaitGroup)
dialBarrier.Add(numReceivingUsers + numRegularUsers)
receivingWatchBarrier.Add(numReceivingUsers)
metrics := notifications.NewMetrics(prometheus.NewRegistry())
eg, runCtx := errgroup.WithContext(ctx)
expectedNotificationsIDs := map[uuid.UUID]struct{}{
notificationsLib.TemplateUserAccountCreated: {},
notificationsLib.TemplateUserAccountDeleted: {},
}
mClock := quartz.NewMock(t)
smtpTrap := mClock.Trap().TickerFunc("smtp")
defer smtpTrap.Close()
// Start receiving runners who will receive notifications
receivingRunners := make([]*notifications.Runner, 0, numReceivingUsers)
for i := range numReceivingUsers {
runnerCfg := notifications.Config{
User: createusers.Config{
OrganizationID: firstUser.OrganizationID,
Username: "receiving-user-" + strconv.Itoa(i),
},
Roles: []string{codersdk.RoleOwner},
NotificationTimeout: testutil.WaitLong,
DialTimeout: testutil.WaitLong,
Metrics: metrics,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: receivingWatchBarrier,
ExpectedNotificationsIDs: expectedNotificationsIDs,
SMTPApiURL: smtpAPIServer.URL,
}
err := runnerCfg.Validate()
require.NoError(t, err)
runner := notifications.NewRunner(client, runnerCfg).WithClock(mClock)
receivingRunners = append(receivingRunners, runner)
eg.Go(func() error {
return runner.Run(runCtx, "receiving-"+strconv.Itoa(i), io.Discard)
})
}
// Start regular user runners who will maintain websocket connections
regularRunners := make([]*notifications.Runner, 0, numRegularUsers)
for i := range numRegularUsers {
runnerCfg := notifications.Config{
User: createusers.Config{
OrganizationID: firstUser.OrganizationID,
},
Roles: []string{},
NotificationTimeout: testutil.WaitLong,
DialTimeout: testutil.WaitLong,
Metrics: metrics,
DialBarrier: dialBarrier,
ReceivingWatchBarrier: receivingWatchBarrier,
}
err := runnerCfg.Validate()
require.NoError(t, err)
runner := notifications.NewRunner(client, runnerCfg)
regularRunners = append(regularRunners, runner)
eg.Go(func() error {
return runner.Run(runCtx, "regular-"+strconv.Itoa(i), io.Discard)
})
}
// Trigger notifications by creating and deleting a user
eg.Go(func() error {
// Wait for all runners to connect
dialBarrier.Wait()
for i := 0; i < numReceivingUsers; i++ {
smtpTrap.MustWait(runCtx).MustRelease(runCtx)
}
for i := 0; i < numReceivingUsers; i++ {
err := sendInboxNotification(runCtx, t, db, inboxHandler, "receiving-user-"+strconv.Itoa(i), notificationsLib.TemplateUserAccountCreated)
require.NoError(t, err)
err = sendInboxNotification(runCtx, t, db, inboxHandler, "receiving-user-"+strconv.Itoa(i), notificationsLib.TemplateUserAccountDeleted)
require.NoError(t, err)
}
_, w := mClock.AdvanceNext()
w.MustWait(runCtx)
return nil
})
err := eg.Wait()
require.NoError(t, err, "runner execution with SMTP should complete successfully")
cleanupEg, cleanupCtx := errgroup.WithContext(ctx)
for i, runner := range receivingRunners {
cleanupEg.Go(func() error {
return runner.Cleanup(cleanupCtx, "receiving-"+strconv.Itoa(i), io.Discard)
})
}
for i, runner := range regularRunners {
cleanupEg.Go(func() error {
return runner.Cleanup(cleanupCtx, "regular-"+strconv.Itoa(i), io.Discard)
})
}
err = cleanupEg.Wait()
require.NoError(t, err)
users, err := client.Users(ctx, codersdk.UsersRequest{})
require.NoError(t, err)
require.Len(t, users.Users, 1)
require.Equal(t, firstUser.UserID, users.Users[0].ID)
// Verify that notifications were received via both websocket and SMTP
for _, runner := range receivingRunners {
metrics := runner.GetMetrics()
websocketReceiptTimes := metrics[notifications.WebsocketNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time)
smtpReceiptTimes := metrics[notifications.SMTPNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time)
require.Contains(t, websocketReceiptTimes, notificationsLib.TemplateUserAccountCreated)
require.Contains(t, websocketReceiptTimes, notificationsLib.TemplateUserAccountDeleted)
require.Contains(t, smtpReceiptTimes, notificationsLib.TemplateUserAccountCreated)
require.Contains(t, smtpReceiptTimes, notificationsLib.TemplateUserAccountDeleted)
func defaultNotificationsConfig(method database.NotificationMethod) codersdk.NotificationsConfig {
return codersdk.NotificationsConfig{
Method: serpent.String(method),
MaxSendAttempts: 5,
FetchInterval: serpent.Duration(time.Millisecond * 100),
StoreSyncInterval: serpent.Duration(time.Millisecond * 200),
LeasePeriod: serpent.Duration(time.Second * 10),
DispatchTimeout: serpent.Duration(time.Second * 5),
RetryInterval: serpent.Duration(time.Millisecond * 50),
LeaseCount: 10,
StoreSyncBufferSize: 50,
Inbox: codersdk.NotificationsInboxConfig{
Enabled: serpent.Bool(true),
},
}
}
func sendInboxNotification(ctx context.Context, t *testing.T, db database.Store, inboxHandler *dispatch.InboxHandler, username string, templateID uuid.UUID) error {
user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Username: username,
})
require.NoError(t, err)
dispatchFunc, err := inboxHandler.Dispatcher(types.MessagePayload{
UserID: user.ID.String(),
NotificationTemplateID: templateID.String(),
}, "", "", nil)
if err != nil {
return err
func defaultHelpers() map[string]any {
return map[string]any{
"base_url": func() string { return "http://test.com" },
"current_year": func() string { return "2024" },
"logo_url": func() string { return "https://coder.com/coder-logo-horizontal.png" },
"app_name": func() string { return "Coder" },
}
_, err = dispatchFunc(ctx, uuid.New())
if err != nil {
return err
}
return nil
}
-247
View File
@@ -1,247 +0,0 @@
package smtpmock
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/quotedprintable"
"net"
"net/http"
"net/mail"
"regexp"
"slices"
"strings"
"time"
"github.com/google/uuid"
smtpmocklib "github.com/mocktools/go-smtp-mock/v2"
"golang.org/x/xerrors"
"cdr.dev/slog"
)
// Server wraps the SMTP mock server and provides an HTTP API to retrieve emails.
type Server struct {
smtpServer *smtpmocklib.Server
httpServer *http.Server
httpListener net.Listener
logger slog.Logger
hostAddress string
smtpPort int
apiPort int
}
type Config struct {
HostAddress string
SMTPPort int
APIPort int
Logger slog.Logger
}
type EmailSummary struct {
Subject string `json:"subject"`
Date time.Time `json:"date"`
NotificationTemplateID uuid.UUID `json:"notification_template_id,omitempty"`
}
var notificationTemplateIDRegex = regexp.MustCompile(`notifications\?disabled=([a-f0-9-]+)`)
func (s *Server) Start(ctx context.Context, cfg Config) error {
s.hostAddress = cfg.HostAddress
s.smtpPort = cfg.SMTPPort
s.apiPort = cfg.APIPort
s.logger = cfg.Logger
s.smtpServer = smtpmocklib.New(smtpmocklib.ConfigurationAttr{
LogToStdout: false,
LogServerActivity: true,
HostAddress: s.hostAddress,
PortNumber: s.smtpPort,
})
if err := s.smtpServer.Start(); err != nil {
return xerrors.Errorf("start SMTP server: %w", err)
}
s.smtpPort = s.smtpServer.PortNumber()
if err := s.startAPIServer(ctx); err != nil {
_ = s.smtpServer.Stop()
return xerrors.Errorf("start API server: %w", err)
}
return nil
}
func (s *Server) Stop() error {
var httpErr, smtpErr error
if s.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
httpErr = xerrors.Errorf("shutdown HTTP server: %w", err)
}
}
if s.smtpServer != nil {
if err := s.smtpServer.Stop(); err != nil {
smtpErr = xerrors.Errorf("stop SMTP server: %w", err)
}
}
return errors.Join(httpErr, smtpErr)
}
func (s *Server) SMTPAddress() string {
return fmt.Sprintf("%s:%d", s.hostAddress, s.smtpPort)
}
func (s *Server) APIAddress() string {
return fmt.Sprintf("http://%s:%d", s.hostAddress, s.apiPort)
}
func (s *Server) MessageCount() int {
if s.smtpServer == nil {
return 0
}
return len(s.smtpServer.Messages())
}
func (s *Server) Purge() {
if s.smtpServer != nil {
s.smtpServer.MessagesAndPurge()
}
}
func (s *Server) startAPIServer(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("POST /purge", s.handlePurge)
mux.HandleFunc("GET /messages", s.handleMessages)
s.httpServer = &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.hostAddress, s.apiPort))
if err != nil {
return xerrors.Errorf("listen on %s:%d: %w", s.hostAddress, s.apiPort, err)
}
s.httpListener = listener
tcpAddr, valid := listener.Addr().(*net.TCPAddr)
if !valid {
err := listener.Close()
if err != nil {
s.logger.Error(ctx, "failed to close listener", slog.Error(err))
}
return xerrors.Errorf("listener returned invalid address: %T", listener.Addr())
}
s.apiPort = tcpAddr.Port
go func() {
if err := s.httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error(ctx, "http API server error", slog.Error(err))
}
}()
return nil
}
func (s *Server) handlePurge(w http.ResponseWriter, _ *http.Request) {
s.smtpServer.MessagesAndPurge()
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
msgs := s.smtpServer.Messages()
var summaries []EmailSummary
for _, msg := range msgs {
recipients := msg.RcpttoRequestResponse()
if !matchesRecipient(recipients, email) {
continue
}
summary, err := parseEmailSummary(msg.MsgRequest())
if err != nil {
s.logger.Warn(r.Context(), "failed to parse email summary", slog.Error(err))
continue
}
summaries = append(summaries, summary)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summaries); err != nil {
s.logger.Warn(r.Context(), "failed to encode JSON response", slog.Error(err))
}
}
func matchesRecipient(recipients [][]string, email string) bool {
if email == "" {
return true
}
return slices.ContainsFunc(recipients, func(rcptPair []string) bool {
if len(rcptPair) == 0 {
return false
}
addrPart, ok := strings.CutPrefix(rcptPair[0], "RCPT TO:")
if !ok {
return false
}
addr, err := mail.ParseAddress(addrPart)
if err != nil {
return false
}
return strings.EqualFold(addr.Address, email)
})
}
func parseEmailSummary(message string) (EmailSummary, error) {
var summary EmailSummary
// Decode quoted-printable message
reader := quotedprintable.NewReader(strings.NewReader(message))
content, err := io.ReadAll(reader)
if err != nil {
return summary, xerrors.Errorf("decode email content: %w", err)
}
contentStr := string(content)
scanner := bufio.NewScanner(strings.NewReader(contentStr))
// Extract Subject and Date from headers.
// Date is used to measure latency.
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break
}
if prefix, found := strings.CutPrefix(line, "Subject: "); found {
summary.Subject = prefix
} else if prefix, found := strings.CutPrefix(line, "Date: "); found {
if parsedDate, err := time.Parse(time.RFC1123Z, prefix); err == nil {
summary.Date = parsedDate
}
}
}
// Extract notification ID from decoded email content
// Notification ID is present in the email footer like this
// <p><a href="http://127.0.0.1:3000/settings/notifications?disabled=4e19c0ac-94e1-4532-9515-d1801aa283b2" style="color: #2563eb; text-decoration: none;">Stop receiving emails like this</a></p>
if matches := notificationTemplateIDRegex.FindStringSubmatch(contentStr); len(matches) > 1 {
summary.NotificationTemplateID, err = uuid.Parse(matches[1])
if err != nil {
return summary, xerrors.Errorf("parse notification ID: %w", err)
}
}
return summary, nil
}
-203
View File
@@ -1,203 +0,0 @@
package smtpmock_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/smtp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/scaletest/smtpmock"
"github.com/coder/coder/v2/testutil"
)
func TestServer_StartStop(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := new(smtpmock.Server)
err := srv.Start(ctx, smtpmock.Config{
HostAddress: "127.0.0.1",
SMTPPort: 0,
APIPort: 0,
Logger: slogtest.Make(t, nil),
})
require.NoError(t, err)
require.NotEmpty(t, srv.SMTPAddress())
require.NotEmpty(t, srv.APIAddress())
err = srv.Stop()
require.NoError(t, err)
}
func TestServer_SendAndReceiveEmail(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := new(smtpmock.Server)
err := srv.Start(ctx, smtpmock.Config{
HostAddress: "127.0.0.1",
SMTPPort: 0,
APIPort: 0,
Logger: slogtest.Make(t, nil),
})
require.NoError(t, err)
defer srv.Stop()
err = sendTestEmail(srv.SMTPAddress(), "test@example.com", "Test Subject", "Test Body")
require.NoError(t, err)
require.Eventually(t, func() bool {
return srv.MessageCount() == 1
}, testutil.WaitShort, testutil.IntervalMedium)
url := fmt.Sprintf("%s/messages", srv.APIAddress())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var summaries []smtpmock.EmailSummary
err = json.NewDecoder(resp.Body).Decode(&summaries)
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, "Test Subject", summaries[0].Subject)
}
func TestServer_FilterByEmail(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := new(smtpmock.Server)
err := srv.Start(ctx, smtpmock.Config{
HostAddress: "127.0.0.1",
SMTPPort: 0,
APIPort: 0,
Logger: slogtest.Make(t, nil),
})
require.NoError(t, err)
defer srv.Stop()
err = sendTestEmail(srv.SMTPAddress(), "admin@coder.com", "Email for admin", "Body 1")
require.NoError(t, err)
err = sendTestEmail(srv.SMTPAddress(), "test-user@coder.com", "Email for test-user", "Body 2")
require.NoError(t, err)
require.Eventually(t, func() bool {
return srv.MessageCount() == 2
}, testutil.WaitShort, testutil.IntervalMedium)
url := fmt.Sprintf("%s/messages?email=admin@coder.com", srv.APIAddress())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var summaries []smtpmock.EmailSummary
err = json.NewDecoder(resp.Body).Decode(&summaries)
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, "Email for admin", summaries[0].Subject)
}
func TestServer_NotificationTemplateID(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := new(smtpmock.Server)
err := srv.Start(ctx, smtpmock.Config{
HostAddress: "127.0.0.1",
SMTPPort: 0,
APIPort: 0,
Logger: slogtest.Make(t, nil),
})
require.NoError(t, err)
defer srv.Stop()
notificationID := uuid.New()
body := fmt.Sprintf(`<p><a href=3D"http://127.0.0.1:3000/settings/notifications?disabled=3D%s">Unsubscribe</a></p>`, notificationID.String())
err = sendTestEmail(srv.SMTPAddress(), "test-user@coder.com", "Notification", body)
require.NoError(t, err)
require.Eventually(t, func() bool {
return srv.MessageCount() == 1
}, testutil.WaitShort, testutil.IntervalMedium)
url := fmt.Sprintf("%s/messages", srv.APIAddress())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var summaries []smtpmock.EmailSummary
err = json.NewDecoder(resp.Body).Decode(&summaries)
require.NoError(t, err)
require.Len(t, summaries, 1)
require.Equal(t, notificationID, summaries[0].NotificationTemplateID)
}
func TestServer_Purge(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := new(smtpmock.Server)
err := srv.Start(ctx, smtpmock.Config{
HostAddress: "127.0.0.1",
SMTPPort: 0,
APIPort: 0,
Logger: slogtest.Make(t, nil),
})
require.NoError(t, err)
defer srv.Stop()
err = sendTestEmail(srv.SMTPAddress(), "test-user@coder.com", "Test", "Body")
require.NoError(t, err)
require.Eventually(t, func() bool {
return srv.MessageCount() == 1
}, testutil.WaitShort, testutil.IntervalMedium)
url := fmt.Sprintf("%s/purge", srv.APIAddress())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, srv.MessageCount())
}
func sendTestEmail(smtpAddr, to, subject, body string) error {
from := "noreply@coder.com"
now := time.Now().Format(time.RFC1123Z)
msg := strings.Builder{}
_, _ = msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
_, _ = msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
_, _ = msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
_, _ = msg.WriteString(fmt.Sprintf("Date: %s\r\n", now))
_, _ = msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
_, _ = msg.WriteString("\r\n")
_, _ = msg.WriteString(body)
return smtp.SendMail(smtpAddr, nil, from, []string{to}, []byte(msg.String()))
}
+1 -4
View File
@@ -133,10 +133,7 @@ func databaseImport(m dsl.Matcher) {
m.Import("github.com/coder/coder/v2/coderd/database")
m.Match("database.$_").
Report("Do not import any database types into codersdk").
Where(
m.File().PkgPath.Matches("github.com/coder/coder/v2/codersdk") &&
!m.File().Name.Matches(`_test\.go$`),
)
Where(m.File().PkgPath.Matches("github.com/coder/coder/v2/codersdk"))
}
// publishInTransaction detects calls to Publish inside database transactions
+2 -4
View File
@@ -26,7 +26,7 @@ export interface AIBridgeConfig {
// From codersdk/aibridge.go
export interface AIBridgeInterception {
readonly id: string;
readonly initiator: MinimalUser;
readonly initiator_id: string;
readonly provider: string;
readonly model: string;
// empty interface{} type, falling back to unknown
@@ -141,7 +141,6 @@ export interface APIKey {
readonly scopes: readonly APIKeyScope[];
readonly token_name: string;
readonly lifetime_seconds: number;
readonly allow_list: readonly APIAllowListTarget[];
}
// From codersdk/apikey.go
@@ -1756,7 +1755,6 @@ export interface DeploymentValues {
readonly session_lifetime?: SessionLifetime;
readonly disable_password_auth?: boolean;
readonly support?: SupportConfig;
readonly enable_authz_recording?: boolean;
readonly external_auth?: SerpentStruct<ExternalAuthConfig[]>;
readonly config_ssh?: SSHConfig;
readonly wgtunnel_host?: string;
@@ -2639,7 +2637,6 @@ export interface MinimalOrganization {
export interface MinimalUser {
readonly id: string;
readonly username: string;
readonly name?: string;
readonly avatar_url?: string;
}
@@ -3953,6 +3950,7 @@ export interface RateLimitConfig {
* required by the frontend.
*/
export interface ReducedUser extends MinimalUser {
readonly name?: string;
readonly email: string;
readonly created_at: string;
readonly updated_at: string;
+8 -5
View File
@@ -1,6 +1,5 @@
import type { Interpolation, Theme } from "@emotion/react";
import Collapse from "@mui/material/Collapse";
import Link from "@mui/material/Link";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import type { FC, ReactNode } from "react";
@@ -20,18 +19,18 @@ export const Expander: FC<ExpanderProps> = ({
return (
<>
{!expanded && (
<Link onClick={toggleExpanded} css={styles.expandLink}>
<button onClick={toggleExpanded} css={styles.expandLink}>
<span css={styles.text}>
Click here to learn more
<DropdownArrow margin={false} />
</span>
</Link>
</button>
)}
<Collapse in={expanded}>
<div css={styles.text}>{children}</div>
</Collapse>
{expanded && (
<Link
<button
onClick={toggleExpanded}
css={[styles.expandLink, styles.collapseLink]}
>
@@ -39,7 +38,7 @@ export const Expander: FC<ExpanderProps> = ({
Click here to hide
<DropdownArrow margin={false} close />
</span>
</Link>
</button>
)}
</>
);
@@ -49,6 +48,10 @@ const styles = {
expandLink: (theme) => ({
cursor: "pointer",
color: theme.palette.text.secondary,
background: "none",
border: "none",
padding: 0,
font: "inherit",
}),
collapseLink: {
marginTop: 16,
@@ -1,13 +1,12 @@
import type { Interpolation, Theme } from "@emotion/react";
import AlertTitle from "@mui/material/AlertTitle";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import type { ApiErrorResponse } from "api/errors";
import type { ExternalAuthDevice } from "api/typesGenerated";
import { isAxiosError } from "axios";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { CopyButton } from "components/CopyButton/CopyButton";
import { ExternalLinkIcon } from "lucide-react";
import { Link } from "components/Link/Link";
import type { FC } from "react";
interface GitDeviceAuthProps {
@@ -150,7 +149,6 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-icon-xs" />
Open and Paste
</Link>
</div>
@@ -4,7 +4,7 @@ import {
type Interpolation,
type Theme,
} from "@emotion/react";
import Link from "@mui/material/Link";
import { Link } from "components/Link/Link";
import { Stack } from "components/Stack/Stack";
import {
Tooltip,
@@ -14,7 +14,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleHelpIcon, ExternalLinkIcon } from "lucide-react";
import { CircleHelpIcon } from "lucide-react";
import {
type FC,
forwardRef,
@@ -141,8 +141,13 @@ interface HelpTooltipLink {
export const HelpTooltipLink: FC<HelpTooltipLink> = ({ children, href }) => {
return (
<Link href={href} target="_blank" rel="noreferrer" css={styles.link}>
<ExternalLinkIcon className="size-icon-xs" css={styles.linkIcon} />
<Link
href={href}
target="_blank"
rel="noreferrer"
size="sm"
css={styles.link}
>
{children}
</Link>
);
@@ -214,13 +219,6 @@ const styles = {
color: theme.roles.active.fill.outline,
}),
linkIcon: {
color: "inherit",
width: 14,
height: 14,
marginRight: 8,
},
linksGroup: {
marginTop: 16,
},
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { Link } from "components/Link/Link";
import {
Table,
TableBody,
+2 -1
View File
@@ -1,7 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { Link } from "components/Link/Link";
import { Stack } from "components/Stack/Stack";
import { CircleCheckBigIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
@@ -33,6 +33,7 @@ export const Paywall: FC<PaywallProps> = ({
target="_blank"
rel="noreferrer"
className="font-semibold"
size="sm"
>
Read the documentation
</Link>
@@ -1,7 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { Link } from "components/Link/Link";
import { Stack } from "components/Stack/Stack";
import { CircleCheckBigIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
@@ -39,6 +39,7 @@ export const PopoverPaywall: FC<PopoverPaywallProps> = ({
target="_blank"
rel="noreferrer"
css={{ fontWeight: 600 }}
size="sm"
>
Read the documentation
</Link>
+2 -1
View File
@@ -1,5 +1,4 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type {
WorkspaceAgent,
@@ -13,6 +12,7 @@ import {
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import { Link } from "components/Link/Link";
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
@@ -74,6 +74,7 @@ const StartTimeoutLifecycle: FC<AgentStatusProps> = ({ agent }) => {
target="_blank"
rel="noreferrer"
href={agent.troubleshooting_url}
size="sm"
>
Troubleshoot
</Link>
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { AuditLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { BuildAuditDescription } from "./BuildAuditDescription";
@@ -52,8 +52,10 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
<span>
{truncatedDescription}
{auditLog.resource_link ? (
<Link component={RouterLink} to={auditLog.resource_link}>
<strong>{target}</strong>
<Link asChild>
<RouterLink to={auditLog.resource_link}>
<strong>{target}</strong>
</RouterLink>
</Link>
) : (
<strong>{target}</strong>
@@ -70,8 +72,10 @@ function AppSessionAuditLogDescription({ auditLog }: AuditLogDescriptionProps) {
return (
<>
{connection_type} session to {workspace_owner}'s{" "}
<Link component={RouterLink} to={`${auditLog.resource_link}`}>
<strong>{workspace_name}</strong>
<Link asChild>
<RouterLink to={`${auditLog.resource_link}`}>
<strong>{workspace_name}</strong>
</RouterLink>
</Link>{" "}
workspace{" "}
<strong>{auditLog.action === "disconnect" ? "closed" : "opened"}</strong>
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { AuditLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import { type FC, useMemo } from "react";
import { Link as RouterLink } from "react-router";
import { systemBuildReasons } from "utils/workspace";
@@ -38,8 +38,10 @@ export const BuildAuditDescription: FC<BuildAuditDescriptionProps> = ({
<span>
{user} <strong>{action}</strong> workspace{" "}
{auditLog.resource_link ? (
<Link component={RouterLink} to={auditLog.resource_link}>
<strong>{workspaceName}</strong>
<Link asChild>
<RouterLink to={auditLog.resource_link}>
<strong>{workspaceName}</strong>
</RouterLink>
</Link>
) : (
<strong>{workspaceName}</strong>
@@ -1,10 +1,10 @@
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
import Collapse from "@mui/material/Collapse";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type { AuditLog, BuildReason } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Link } from "components/Link/Link";
import { Stack } from "components/Stack/Stack";
import { StatusPill } from "components/StatusPill/StatusPill";
import { TableCell } from "components/Table/Table";
@@ -157,12 +157,13 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
<h4 css={styles.auditLogInfoHeader}>
Organization:
</h4>
<Link
component={RouterLink}
to={`/organizations/${auditLog.organization.name}`}
>
{auditLog.organization.display_name ||
auditLog.organization.name}
<Link asChild size="sm">
<RouterLink
to={`/organizations/${auditLog.organization.name}`}
>
{auditLog.organization.display_name ||
auditLog.organization.name}
</RouterLink>
</Link>
</div>
)}
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { ConnectionLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import type { FC, ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import { connectionTypeToFriendlyName } from "utils/connection";
@@ -62,11 +62,10 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
<span>
{user ? user.username : "Unauthenticated user"} {actionText} in{" "}
{isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "}
<Link
component={RouterLink}
to={`/@${workspace_owner_username}/${workspace_name}`}
>
<strong>{workspace_name}</strong>
<Link asChild>
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
<strong>{workspace_name}</strong>
</RouterLink>
</Link>{" "}
workspace
</span>
@@ -81,11 +80,10 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
return (
<span>
{friendlyType} session to {workspace_owner_username}'s{" "}
<Link
component={RouterLink}
to={`/@${workspace_owner_username}/${workspace_name}`}
>
<strong>{workspace_name}</strong>
<Link asChild>
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
<strong>{workspace_name}</strong>
</RouterLink>
</Link>{" "}
workspace{" "}
</span>
@@ -1,8 +1,8 @@
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import type { ConnectionLog } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Link } from "components/Link/Link";
import { Stack } from "components/Stack/Stack";
import { StatusPill } from "components/StatusPill/StatusPill";
import { TableCell } from "components/Table/Table";
@@ -115,12 +115,13 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
<h4 css={styles.connectionLogInfoheader}>
Organization:
</h4>
<Link
component={RouterLink}
to={`/organizations/${connectionLog.organization.name}`}
>
{connectionLog.organization.display_name ||
connectionLog.organization.name}
<Link asChild size="sm">
<RouterLink
to={`/organizations/${connectionLog.organization.name}`}
>
{connectionLog.organization.display_name ||
connectionLog.organization.name}
</RouterLink>
</Link>
</div>
)}
@@ -1,4 +1,3 @@
import Link from "@mui/material/Link";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { hasApiFieldErrors, isApiError } from "api/errors";
@@ -7,6 +6,7 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { Link } from "components/Link/Link";
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
import { PasswordField } from "components/PasswordField/PasswordField";
import { Spinner } from "components/Spinner/Spinner";
@@ -1,6 +1,6 @@
import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField";
import { Button } from "components/Button/Button";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { useFormik } from "formik";
@@ -69,16 +69,8 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
<Spinner loading={isSigningIn} />
{Language.passwordSignIn}
</Button>
<Link
component={RouterLink}
to="/reset-password"
css={{
fontSize: 12,
fontWeight: 500,
lineHeight: "16px",
}}
>
Forgot password?
<Link asChild size="sm">
<RouterLink to="/reset-password">Forgot password?</RouterLink>
</Link>
</Stack>
</form>
-2
View File
@@ -85,7 +85,6 @@ export const MockToken: TypesGen.APIKeyWithOwner = {
login_type: "token",
scope: "all",
scopes: ["coder:all"],
allow_list: [{ type: "*", id: "*" }],
lifetime_seconds: 2592000,
token_name: "token-one",
username: "admin",
@@ -103,7 +102,6 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [
login_type: "token",
scope: "all",
scopes: ["coder:all"],
allow_list: [{ type: "*", id: "*" }],
lifetime_seconds: 2592000,
token_name: "token-two",
username: "admin",
+3 -3
View File
@@ -1,6 +1,6 @@
import Link from "@mui/material/Link";
import type { Template, Workspace } from "api/typesGenerated";
import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip";
import { Link } from "components/Link/Link";
import cronParser from "cron-parser";
import cronstrue from "cronstrue";
import dayjs, { type Dayjs } from "dayjs";
@@ -149,8 +149,8 @@ export const autostopDisplay = (
{" "}
because this workspace has enabled autostop. You can disable autostop
from this workspace&apos;s{" "}
<Link component={RouterLink} to="settings/schedule">
schedule settings
<Link asChild size="sm">
<RouterLink to="settings/schedule">schedule settings</RouterLink>
</Link>
.
</span>
+167 -159
View File
@@ -1,179 +1,187 @@
{{/*
This template is used by application handlers to render friendly error pages
when there is a proxy error (for example, when the target app isn't running).
This template is used by application handlers to render friendly error pages
when there is a proxy error (for example, when the target app isn't running).
Since it is served from subdomains, both on proxies and the primary, it MUST
NOT access any external resources. It must be entirely self-contained. This
includes anything in `/static` or `/icon`, as these are not served from
subdomains.
Since it is served from subdomains, both on proxies and the primary, it MUST
NOT access any external resources. It must be entirely self-contained. This
includes anything in `/static` or `/icon`, as these are not served from
subdomains.
*/}}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{- if not .Error.HideStatus }}{{ .Error.Status }} - {{end}}{{
.Error.Title }}
</title>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{- if not .Error.HideStatus }}{{ .Error.Status }} - {{end}}{{
.Error.Title }}
</title>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
html,
body {
background-color: #05060b;
color: #a1a1aa;
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
font-size: 16px;
height: 100%;
}
html,
body {
background-color: #05060b;
color: #f7f9fd;
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
font-size: 16px;
height: 100%;
}
header {
text-align: center;
}
.container {
--side-padding: 24px;
width: 100%;
max-width: calc(500px + var(--side-padding) * 2);
padding: 0 var(--side-padding);
text-align: center;
}
.container {
--side-padding: 24px;
width: 100%;
max-width: calc(500px + var(--side-padding) * 2);
padding: 0 var(--side-padding);
}
.coder-svg {
width: 80px;
margin-bottom: 24px;
}
.coder-svg {
width: 80px;
margin-bottom: 24px;
}
h1 {
font-weight: 700;
font-size: 36px;
margin-bottom: 8px;
}
h1 {
font-size: 24px;
margin-bottom: 8px;
font-weight: 400;
color: white;
}
p,
li {
color: #b2bfd7;
line-height: 140%;
}
p,
li {
line-height: 140%;
font-size: 14px;
}
.warning li {
text-align: left;
padding-top: 10px;
margin-left: 30px;
}
.button-group {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.button-group {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.button-group a,
.button-group button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
border-radius: 4px;
border: 1px solid #2c3854;
text-decoration: none;
background: none;
font-size: inherit;
color: inherit;
width: 200px;
height: 42px;
cursor: pointer;
}
.button-group a,
.button-group button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
border-radius: 4px;
border: 1px solid #2c3854;
text-decoration: none;
background: none;
font-size: inherit;
color: inherit;
width: 200px;
height: 42px;
cursor: pointer;
}
.button-group a:hover,
.button-group button:hover {
border-color: hsl(222, 31%, 40%);
}
.button-group a:hover,
.button-group button:hover {
border-color: hsl(222, 31%, 40%);
}
.warning {
margin-top: 24px;
border: 1px solid rgb(243, 140, 89);
background: rgb(13, 19, 33);
width: calc(520px + var(--side-padding) * 2);
/* Recenter */
margin-left: calc(-1 * (100px + var(--side-padding)));
padding: 10px;
}
.warning {
margin-top: 24px;
border: 1px solid rgb(243, 140, 89);
background: rgb(13, 19, 33);
width: 100%;
padding: 24px;
}
.warning-title {
display: inline-flex;
align-self: center;
align-items: center;
}
.warning-title {
display: inline-flex;
align-self: center;
align-items: center;
}
.svg-icon svg {
height: 1em;
width: 1em;
}
.warning-title h3 {
margin-left: 12px;
font-size: 16px;
font-weight: 500;
color: white;
}
.warning-title h3 {
margin-left: 10px;
}
</style>
</head>
<body>
<div class="container">
{{/*
DO NOT LOAD AN EXTERNAL IMAGE HERE. See the comment at the top of
this file for more details.
*/}}
<svg
class="coder-svg"
viewBox="0 0 66 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M18.996 7C29.9934 7.00003 36.1588 12.5745 36.3671 20.7799L26.8691 21.0919C26.6191 16.5433 22.8492 13.5554 18.996 13.6445C13.7055 13.756 9.78938 17.5243 9.78934 23.5C9.78934 29.4757 13.7054 33.1773 18.996 33.1773C22.8492 33.177 26.5357 30.323 26.9524 25.7743L36.4503 25.9974C36.2004 34.3365 29.6602 40 18.996 40C8.33164 40 0 33.5338 0 23.5C3.90577e-05 13.4216 7.9984 7 18.996 7ZM66 7.97504V39.1914H41.0058V7.97504H66Z" fill="white" />
</svg>
.warning li {
padding-top: 10px;
margin-left: 30px;
}
</style>
</head>
<body>
<div class="container">
<header>
{{/*
DO NOT LOAD AN EXTERNAL IMAGE HERE. See the comment at the top of
this file for more details.
*/}}
<svg class="coder-svg" viewBox="0 0 66 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18.996 7C29.9934 7.00003 36.1588 12.5745 36.3671 20.7799L26.8691 21.0919C26.6191 16.5433 22.8492 13.5554 18.996 13.6445C13.7055 13.756 9.78938 17.5243 9.78934 23.5C9.78934 29.4757 13.7054 33.1773 18.996 33.1773C22.8492 33.177 26.5357 30.323 26.9524 25.7743L36.4503 25.9974C36.2004 34.3365 29.6602 40 18.996 40C8.33164 40 0 33.5338 0 23.5C3.90577e-05 13.4216 7.9984 7 18.996 7ZM66 7.97504V39.1914H41.0058V7.97504H66Z"
fill="white" />
</svg>
<h1>
{{- if not .Error.HideStatus }}{{ .Error.Status }} - {{end}}{{
.Error.Title }}
</h1>
{{- if .Error.RenderDescriptionMarkdown }} {{ .ErrorDescriptionHTML }} {{
else }}
<p>{{ .Error.Description }}</p>
{{ end }} {{- if .Error.AdditionalInfo }}
<br />
<p>{{ .Error.AdditionalInfo }}</p>
{{ end }}
</header>
{{- if .Error.Warnings }}
<div class="warning">
<div class="warning-title">
<svg height="1em" width="auto" focusable="false" aria-hidden="true" viewBox="0 0 24 24">
<path fill="#e66828" d="M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z">
</path>
</svg>
<h3>Warnings</h3>
</div>
<ul>
{{ range $i, $v := .Error.Warnings }}
<li>{{ $v }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<div class="button-group">
{{- if and .Error.AdditionalButtonText .Error.AdditionalButtonLink }}
<a href="{{ .Error.AdditionalButtonLink }}">{{ .Error.AdditionalButtonText }}</a>
{{ end }} {{- if .Error.RetryEnabled }}
<button onclick="window.location.reload()">Retry</button>
{{ end }}
<a href="{{ .Error.DashboardURL }}">Back to site</a>
</div>
</div>
</body>
<h1>
{{- if not .Error.HideStatus }}{{ .Error.Status }} - {{end}}{{
.Error.Title }}
</h1>
{{- if .Error.RenderDescriptionMarkdown }} {{ .ErrorDescriptionHTML }} {{
else }}
<p>{{ .Error.Description }}</p>
{{ end }} {{- if .Error.AdditionalInfo }}
<br />
<p>{{ .Error.AdditionalInfo }}</p>
{{ end }} {{- if .Error.Warnings }}
<div class="warning">
<div class="warning-title">
<svg
height="1em"
width="auto"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path
fill="#e66828"
d="M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z"
></path>
</svg>
<h3>Warnings</h3>
</div>
<ul>
{{ range $i, $v := .Error.Warnings }}
<li>{{ $v }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<div class="button-group">
{{- if and .Error.AdditionalButtonText .Error.AdditionalButtonLink }}
<a href="{{ .Error.AdditionalButtonLink }}"
>{{ .Error.AdditionalButtonText }}</a
>
{{ end }} {{- if .Error.RetryEnabled }}
<button onclick="window.location.reload()">Retry</button>
{{ end }}
<a href="{{ .Error.DashboardURL }}">Back to site</a>
</div>
</div>
</body>
</html>
-1
View File
@@ -102,7 +102,6 @@ func TestClient_WorkspaceUpdates(t *testing.T) {
MinimalUser: codersdk.MinimalUser{
ID: userID,
Username: "rootbeer",
Name: "Root Beer",
},
},
})