Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c50cb7d35 |
+1
-11
@@ -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
@@ -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(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
@@ -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(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
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])
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+3
-17
@@ -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",
|
||||
|
||||
Generated
+3
-17
@@ -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": [
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
-3
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
-6
@@ -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
|
||||
|
||||
Generated
-1
@@ -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": [
|
||||
|
||||
Generated
+16
-42
@@ -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 | | |
|
||||
|
||||
|
||||
Generated
-9
@@ -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,
|
||||
|
||||
Generated
+22
-85
@@ -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",
|
||||
|
||||
Generated
-1
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+5
-10
@@ -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 |
|
||||
|
||||
Generated
-16
@@ -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-...).
|
||||
|
||||
Generated
+4
-4
@@ -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.
|
||||
|
||||
|
||||
Generated
-30
@@ -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.
|
||||
Generated
+4
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-4
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+9
-11
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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's{" "}
|
||||
<Link component={RouterLink} to="settings/schedule">
|
||||
schedule settings
|
||||
<Link asChild size="sm">
|
||||
<RouterLink to="settings/schedule">schedule settings</RouterLink>
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
|
||||
+167
-159
@@ -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>
|
||||
|
||||
@@ -102,7 +102,6 @@ func TestClient_WorkspaceUpdates(t *testing.T) {
|
||||
MinimalUser: codersdk.MinimalUser{
|
||||
ID: userID,
|
||||
Username: "rootbeer",
|
||||
Name: "Root Beer",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user