Compare commits

...

8 Commits

Author SHA1 Message Date
Ethan 1a774ab7ce fix(tailnet): retry after transport dial timeouts (#22977) (cherry-pick/v2.31) (#22992)
Backport of #22977 to 2.31
2026-03-13 14:26:48 -04:00
Rowan Smith 581e956b49 fix: prevent ui error when last org member is removed (#23019)
Backport of #22975 to release/2.31.
2026-03-13 14:22:40 -04:00
Jon Ayers 2cd4e03f11 fix: prevent emitting build duration metric for devcontainer subagents (#22930) 2026-03-10 20:31:05 -05:00
Susana Ferreira 61b513e586 fix: bump aibridge to v1.0.9 to forward Anthropic-Beta header (#22842)
Bumps aibridge to v1.0.9, which forwards the `Anthropic-Beta` header
from client requests to the upstream Anthropic API:
https://github.com/coder/aibridge/pull/205

This fixes the `context_management: Extra inputs are not permitted`
error when using Claude Code with AI Bridge.

Note: v1.0.8 was retracted due to a conflict marker cached by the Go
module proxy https://github.com/coder/aibridge/pull/208. v1.0.9 contains
the same fix.
2026-03-10 15:52:04 -04:00
Jon Ayers 757634c720 fix: filter sub-agents from build duration metric (#22732) (#22919) 2026-03-10 14:11:01 -05:00
Jon Ayers a3792153de feat: add Prometheus collector for DERP server expvar metrics (#22583) (#22917)
backports the derp prometheus metrics
2026-03-10 12:29:15 -05:00
Steven Masley deaacff843 fix: early oidc refresh with fake idp tests (#22712) (cherry 2.31) (#22716)
Confirmed manually using this branch with 5min tokens (always refreshed)
and 15min tokens (refreshed after 5min elapsed)
2026-03-06 14:33:33 -05:00
Steven Masley 2828d28e0c chore: prematurely refresh oidc token near expiry during workspace (cherry 2.31) (#22606)
(cherry picked from commit f49dea683c)
2026-03-04 10:55:40 -06:00
23 changed files with 1297 additions and 49 deletions
+56
View File
@@ -3040,6 +3040,62 @@ func TestAgent_Reconnect(t *testing.T) {
closer.Close()
}
func TestAgent_ReconnectNoLifecycleReemit(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
fCoordinator := tailnettest.NewFakeCoordinator()
agentID := uuid.New()
statsCh := make(chan *proto.Stats, 50)
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
client := agenttest.NewClient(t,
logger,
agentID,
agentsdk.Manifest{
DERPMap: derpMap,
Scripts: []codersdk.WorkspaceAgentScript{{
Script: "echo hello",
Timeout: 30 * time.Second,
RunOnStart: true,
}},
},
statsCh,
fCoordinator,
)
defer client.Close()
closer := agent.New(agent.Options{
Client: client,
Logger: logger.Named("agent"),
})
defer closer.Close()
// Wait for the agent to reach Ready state.
require.Eventually(t, func() bool {
return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady)
}, testutil.WaitShort, testutil.IntervalFast)
statesBefore := slices.Clone(client.GetLifecycleStates())
// Disconnect by closing the coordinator response channel.
call1 := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
close(call1.Resps)
// Wait for reconnect.
testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
// Wait for a stats report as a deterministic steady-state proof.
testutil.RequireReceive(ctx, t, statsCh)
statesAfter := client.GetLifecycleStates()
require.Equal(t, statesBefore, statesAfter,
"lifecycle states should not be re-reported after reconnect")
closer.Close()
}
func TestAgent_WriteVSCodeConfigs(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
+6 -3
View File
@@ -134,9 +134,12 @@ func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.Upda
case database.WorkspaceAgentLifecycleStateReady,
database.WorkspaceAgentLifecycleStateStartTimeout,
database.WorkspaceAgentLifecycleStateStartError:
a.emitMetricsOnce.Do(func() {
a.emitBuildDurationMetric(ctx, workspaceAgent.ResourceID)
})
// Only emit metrics for the parent agent, this metric is not intended to measure devcontainer durations.
if !workspaceAgent.ParentID.Valid {
a.emitMetricsOnce.Do(func() {
a.emitBuildDurationMetric(ctx, workspaceAgent.ResourceID)
})
}
}
return req.Lifecycle, nil
+58
View File
@@ -582,6 +582,64 @@ func TestUpdateLifecycle(t *testing.T) {
require.Equal(t, uint64(1), got.GetSampleCount())
require.Equal(t, expectedDuration, got.GetSampleSum())
})
t.Run("SubAgentDoesNotEmitMetric", func(t *testing.T) {
t.Parallel()
parentID := uuid.New()
subAgent := database.WorkspaceAgent{
ID: uuid.New(),
ParentID: uuid.NullUUID{UUID: parentID, Valid: true},
LifecycleState: database.WorkspaceAgentLifecycleStateStarting,
StartedAt: sql.NullTime{Valid: true, Time: someTime},
ReadyAt: sql.NullTime{Valid: false},
}
lifecycle := &agentproto.Lifecycle{
State: agentproto.Lifecycle_READY,
ChangedAt: timestamppb.New(now),
}
dbM := dbmock.NewMockStore(gomock.NewController(t))
dbM.EXPECT().UpdateWorkspaceAgentLifecycleStateByID(gomock.Any(), database.UpdateWorkspaceAgentLifecycleStateByIDParams{
ID: subAgent.ID,
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
StartedAt: subAgent.StartedAt,
ReadyAt: sql.NullTime{
Time: now,
Valid: true,
},
}).Return(nil)
// GetWorkspaceBuildMetricsByResourceID should NOT be called
// because sub-agents should be skipped before querying.
reg := prometheus.NewRegistry()
metrics := agentapi.NewLifecycleMetrics(reg)
api := &agentapi.LifecycleAPI{
AgentFn: func(ctx context.Context) (database.WorkspaceAgent, error) {
return subAgent, nil
},
WorkspaceID: workspaceID,
Database: dbM,
Log: testutil.Logger(t),
Metrics: metrics,
PublishWorkspaceUpdateFn: nil,
}
resp, err := api.UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{
Lifecycle: lifecycle,
})
require.NoError(t, err)
require.Equal(t, lifecycle, resp)
// We don't expect the metric to be emitted for sub-agents, by default this will fail anyway but it doesn't hurt
// to document the test explicitly.
dbM.EXPECT().GetWorkspaceBuildMetricsByResourceID(gomock.Any(), gomock.Any()).Times(0)
// If we were emitting the metric we would have failed by now since it would include a call to the database that we're not expecting.
pm, err := reg.Gather()
require.NoError(t, err)
for _, m := range pm {
if m.GetName() == fullMetricName {
t.Fatal("metric should not be emitted for sub-agent")
}
}
})
}
func TestUpdateStartup(t *testing.T) {
+5 -3
View File
@@ -98,6 +98,7 @@ import (
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/site"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/derpmetrics"
"github.com/coder/quartz"
"github.com/coder/serpent"
)
@@ -883,17 +884,18 @@ func New(options *Options) *API {
apiRateLimiter := httpmw.RateLimit(options.APIRateLimit, time.Minute)
// Register DERP on expvar HTTP handler, which we serve below in the router, c.f. expvar.Handler()
// These are the metrics the DERP server exposes.
// TODO: export via prometheus
expDERPOnce.Do(func() {
// We need to do this via a global Once because expvar registry is global and panics if we
// register multiple times. In production there is only one Coderd and one DERP server per
// process, but in testing, we create multiple of both, so the Once protects us from
// panicking.
if options.DERPServer != nil {
if options.DERPServer != nil && expvar.Get("derp") == nil {
expvar.Publish("derp", api.DERPServer.ExpVar())
}
})
if options.PrometheusRegistry != nil && options.DERPServer != nil {
options.PrometheusRegistry.MustRegister(derpmetrics.NewDERPExpvarCollector(options.DERPServer))
}
cors := httpmw.Cors(options.DeploymentValues.Dangerous.AllowAllCors.Value())
prometheusMW := httpmw.Prometheus(options.PrometheusRegistry)
+26
View File
@@ -390,3 +390,29 @@ func TestCSRFExempt(t *testing.T) {
require.NotContains(t, string(data), "CSRF")
})
}
func TestDERPMetrics(t *testing.T) {
t.Parallel()
_, _, api := coderdtest.NewWithAPI(t, nil)
require.NotNil(t, api.Options.DERPServer, "DERP server should be configured")
require.NotNil(t, api.Options.PrometheusRegistry, "Prometheus registry should be configured")
// The registry is created internally by coderd. Gather from it
// to verify DERP metrics were registered during startup.
metrics, err := api.Options.PrometheusRegistry.Gather()
require.NoError(t, err)
names := make(map[string]struct{})
for _, m := range metrics {
names[m.GetName()] = struct{}{}
}
assert.Contains(t, names, "coder_derp_server_connections",
"expected coder_derp_server_connections to be registered")
assert.Contains(t, names, "coder_derp_server_bytes_received_total",
"expected coder_derp_server_bytes_received_total to be registered")
assert.Contains(t, names, "coder_derp_server_packets_dropped_reason_total",
"expected coder_derp_server_packets_dropped_reason_total to be registered")
}
+120
View File
@@ -8742,3 +8742,123 @@ func TestInsertWorkspaceAgentDevcontainers(t *testing.T) {
})
}
}
func TestGetWorkspaceBuildMetricsByResourceID(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
TemplateID: uuid.NullUUID{UUID: tmpl.ID, Valid: true},
CreatedBy: user.ID,
})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
TemplateID: tmpl.ID,
OwnerID: user.ID,
AutomaticUpdates: database.AutomaticUpdatesNever,
})
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: ws.ID,
TemplateVersionID: tv.ID,
JobID: job.ID,
InitiatorID: user.ID,
})
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: job.ID,
})
parentReadyAt := dbtime.Now()
parentStartedAt := parentReadyAt.Add(-time.Second)
_ = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: resource.ID,
StartedAt: sql.NullTime{Time: parentStartedAt, Valid: true},
ReadyAt: sql.NullTime{Time: parentReadyAt, Valid: true},
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
})
row, err := db.GetWorkspaceBuildMetricsByResourceID(ctx, resource.ID)
require.NoError(t, err)
require.True(t, row.AllAgentsReady)
require.True(t, parentReadyAt.Equal(row.LastAgentReadyAt))
require.Equal(t, "success", row.WorstStatus)
})
t.Run("SubAgentExcluded", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
TemplateID: uuid.NullUUID{UUID: tmpl.ID, Valid: true},
CreatedBy: user.ID,
})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
TemplateID: tmpl.ID,
OwnerID: user.ID,
AutomaticUpdates: database.AutomaticUpdatesNever,
})
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: ws.ID,
TemplateVersionID: tv.ID,
JobID: job.ID,
InitiatorID: user.ID,
})
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
JobID: job.ID,
})
parentReadyAt := dbtime.Now()
parentStartedAt := parentReadyAt.Add(-time.Second)
parentAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: resource.ID,
StartedAt: sql.NullTime{Time: parentStartedAt, Valid: true},
ReadyAt: sql.NullTime{Time: parentReadyAt, Valid: true},
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
})
// Sub-agent with ready_at 1 hour later should be excluded.
subAgentReadyAt := parentReadyAt.Add(time.Hour)
subAgentStartedAt := subAgentReadyAt.Add(-time.Second)
_ = dbgen.WorkspaceSubAgent(t, db, parentAgent, database.WorkspaceAgent{
StartedAt: sql.NullTime{Time: subAgentStartedAt, Valid: true},
ReadyAt: sql.NullTime{Time: subAgentReadyAt, Valid: true},
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
})
row, err := db.GetWorkspaceBuildMetricsByResourceID(ctx, resource.ID)
require.NoError(t, err)
require.True(t, row.AllAgentsReady)
// LastAgentReadyAt should be the parent's, not the sub-agent's.
require.True(t, parentReadyAt.Equal(row.LastAgentReadyAt))
require.Equal(t, "success", row.WorstStatus)
})
}
+1 -1
View File
@@ -21599,7 +21599,7 @@ JOIN workspaces w ON wb.workspace_id = w.id
JOIN templates t ON w.template_id = t.id
JOIN organizations o ON t.organization_id = o.id
JOIN workspace_resources wr ON wr.job_id = wb.job_id
JOIN workspace_agents wa ON wa.resource_id = wr.id
JOIN workspace_agents wa ON wa.resource_id = wr.id AND wa.parent_id IS NULL
WHERE wb.job_id = (SELECT job_id FROM workspace_resources WHERE workspace_resources.id = $1)
GROUP BY wb.created_at, wb.transition, t.name, o.name, w.owner_id
`
+1 -1
View File
@@ -268,7 +268,7 @@ JOIN workspaces w ON wb.workspace_id = w.id
JOIN templates t ON w.template_id = t.id
JOIN organizations o ON t.organization_id = o.id
JOIN workspace_resources wr ON wr.job_id = wb.job_id
JOIN workspace_agents wa ON wa.resource_id = wr.id
JOIN workspace_agents wa ON wa.resource_id = wr.id AND wa.parent_id IS NULL
WHERE wb.job_id = (SELECT job_id FROM workspace_resources WHERE workspace_resources.id = $1)
GROUP BY wb.created_at, wb.transition, t.name, o.name, w.owner_id;
+9
View File
@@ -287,9 +287,18 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
memberRows = append(memberRows, row)
}
if len(paginatedMemberRows) == 0 {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.PaginatedMembersResponse{
Members: []codersdk.OrganizationMemberWithUserData{},
Count: 0,
})
return
}
members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
resp := codersdk.PaginatedMembersResponse{
@@ -564,7 +564,7 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
// The check `s.OIDCConfig != nil` is not as strict, since it can be an interface
// pointing to a typed nil.
if !reflect.ValueOf(s.OIDCConfig).IsNil() {
workspaceOwnerOIDCAccessToken, err = obtainOIDCAccessToken(ctx, s.Database, s.OIDCConfig, owner.ID)
workspaceOwnerOIDCAccessToken, err = ObtainOIDCAccessToken(ctx, s.Logger, s.Database, s.OIDCConfig, owner.ID)
if err != nil {
return nil, failJob(fmt.Sprintf("obtain OIDC access token: %s", err))
}
@@ -3075,9 +3075,37 @@ func deleteSessionTokenForUserAndWorkspace(ctx context.Context, db database.Stor
return nil
}
// obtainOIDCAccessToken returns a valid OpenID Connect access token
func shouldRefreshOIDCToken(link database.UserLink) (bool, time.Time) {
if link.OAuthRefreshToken == "" {
// We cannot refresh even if we wanted to
return false, link.OAuthExpiry
}
if link.OAuthExpiry.IsZero() {
// 0 expire means the token never expires, so we shouldn't refresh
return false, link.OAuthExpiry
}
// This handles an edge case where the token is about to expire. A workspace
// build takes a non-trivial amount of time. If the token is to expire during the
// build, then the build risks failure. To mitigate this, refresh the token
// prematurely.
//
// If an OIDC provider issues short-lived tokens less than our defined period,
// the token will always be refreshed on every workspace build.
//
// By setting the expiration backwards, we are effectively shortening the
// time a token can be alive for by 10 minutes.
// Note: This is how it is done in the oauth2 package's own token refreshing logic.
expiresAt := link.OAuthExpiry.Add(-time.Minute * 10)
// Return if the token is assumed to be expired.
return expiresAt.Before(dbtime.Now()), expiresAt
}
// ObtainOIDCAccessToken returns a valid OpenID Connect access token
// for the user if it's able to obtain one, otherwise it returns an empty string.
func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig promoauth.OAuth2Config, userID uuid.UUID) (string, error) {
func ObtainOIDCAccessToken(ctx context.Context, logger slog.Logger, db database.Store, oidcConfig promoauth.OAuth2Config, userID uuid.UUID) (string, error) {
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
UserID: userID,
LoginType: database.LoginTypeOIDC,
@@ -3089,11 +3117,13 @@ func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig pr
return "", xerrors.Errorf("get owner oidc link: %w", err)
}
if link.OAuthExpiry.Before(dbtime.Now()) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
if shouldRefresh, expiresAt := shouldRefreshOIDCToken(link); shouldRefresh {
token, err := oidcConfig.TokenSource(ctx, &oauth2.Token{
AccessToken: link.OAuthAccessToken,
RefreshToken: link.OAuthRefreshToken,
Expiry: link.OAuthExpiry,
// Use the expiresAt returned by shouldRefreshOIDCToken.
// It will force a refresh with an expired time.
Expiry: expiresAt,
}).Token()
if err != nil {
// If OIDC fails to refresh, we return an empty string and don't fail.
@@ -3118,6 +3148,7 @@ func obtainOIDCAccessToken(ctx context.Context, db database.Store, oidcConfig pr
if err != nil {
return "", xerrors.Errorf("update user link: %w", err)
}
logger.Info(ctx, "refreshed expired OIDC token for user during workspace build", slog.F("user_id", userID))
}
return link.OAuthAccessToken, nil
@@ -16,13 +16,109 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestShouldRefreshOIDCToken(t *testing.T) {
t.Parallel()
now := dbtime.Now()
testCases := []struct {
name string
link database.UserLink
want bool
}{
{
name: "NoRefreshToken",
link: database.UserLink{OAuthExpiry: now.Add(-time.Hour)},
want: false,
},
{
name: "ZeroExpiry",
link: database.UserLink{OAuthRefreshToken: "refresh"},
want: false,
},
{
name: "LongExpired",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(-1 * time.Hour),
},
want: true,
},
{
// Edge being "+/- 10 minutes"
name: "EdgeExpired",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(-1 * time.Minute * 10),
},
want: true,
},
{
name: "Expired",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(-1 * time.Minute),
},
want: true,
},
{
name: "SoonToBeExpired",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(5 * time.Minute),
},
want: true,
},
{
name: "SoonToBeExpiredEdge",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(9 * time.Minute),
},
want: true,
},
{
name: "AfterEdge",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(11 * time.Minute),
},
want: false,
},
{
name: "NotExpired",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(time.Hour),
},
want: false,
},
{
name: "NotEvenCloseExpired",
link: database.UserLink{
OAuthRefreshToken: "refresh",
OAuthExpiry: now.Add(time.Hour * 24),
},
want: false,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
shouldRefresh, _ := shouldRefreshOIDCToken(tc.link)
require.Equal(t, tc.want, shouldRefresh)
})
}
}
func TestObtainOIDCAccessToken(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("NoToken", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
_, err := obtainOIDCAccessToken(ctx, db, nil, uuid.Nil)
_, err := ObtainOIDCAccessToken(ctx, testutil.Logger(t), db, nil, uuid.Nil)
require.NoError(t, err)
})
t.Run("InvalidConfig", func(t *testing.T) {
@@ -35,7 +131,7 @@ func TestObtainOIDCAccessToken(t *testing.T) {
LoginType: database.LoginTypeOIDC,
OAuthExpiry: dbtime.Now().Add(-time.Hour),
})
_, err := obtainOIDCAccessToken(ctx, db, &oauth2.Config{}, user.ID)
_, err := ObtainOIDCAccessToken(ctx, testutil.Logger(t), db, &oauth2.Config{}, user.ID)
require.NoError(t, err)
})
t.Run("MissingLink", func(t *testing.T) {
@@ -44,7 +140,7 @@ func TestObtainOIDCAccessToken(t *testing.T) {
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
tok, err := obtainOIDCAccessToken(ctx, db, &oauth2.Config{}, user.ID)
tok, err := ObtainOIDCAccessToken(ctx, testutil.Logger(t), db, &oauth2.Config{}, user.ID)
require.Empty(t, tok)
require.NoError(t, err)
})
@@ -57,7 +153,7 @@ func TestObtainOIDCAccessToken(t *testing.T) {
LoginType: database.LoginTypeOIDC,
OAuthExpiry: dbtime.Now().Add(-time.Hour),
})
_, err := obtainOIDCAccessToken(ctx, db, &testutil.OAuth2Config{
_, err := ObtainOIDCAccessToken(ctx, testutil.Logger(t), db, &testutil.OAuth2Config{
Token: &oauth2.Token{
AccessToken: "token",
},
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
@@ -30,6 +31,7 @@ import (
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
@@ -58,6 +60,175 @@ import (
"github.com/coder/serpent"
)
// TestTokenIsRefreshedEarly creates a fake OIDC IDP that sets expiration times
// of the token to values that are "near expiration". Expiration being 10minutes
// earlier than it needs to be. The `ObtainOIDCAccessToken` should refresh these
// tokens early.
func TestTokenIsRefreshedEarly(t *testing.T) {
t.Parallel()
t.Run("WithCoderd", func(t *testing.T) {
t.Parallel()
tokenRefreshCount := 0
fake := oidctest.NewFakeIDP(t,
oidctest.WithServing(),
oidctest.WithDefaultExpire(time.Minute*8),
oidctest.WithRefresh(func(email string) error {
tokenRefreshCount++
return nil
}),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
db, ps := dbtestutil.NewDB(t)
owner := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
IncludeProvisionerDaemon: true,
Database: db,
Pubsub: ps,
})
first := coderdtest.CreateFirstUser(t, owner)
version := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version.ID)
template := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version.ID)
// Setup an OIDC user.
client, _ := fake.Login(t, owner, jwt.MapClaims{
"email": "user@unauthorized.com",
"email_verified": true,
"sub": uuid.NewString(),
})
// Creating a workspace should refresh the oidc early.
tokenRefreshCount = 0
wrk := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, wrk.LatestBuild.ID)
require.Equal(t, 1, tokenRefreshCount)
})
}
//nolint:tparallel,paralleltest // Sub tests need to run sequentially.
func TestTokenIsRefreshedEarlyWithoutCoderd(t *testing.T) {
t.Parallel()
tokenRefreshCount := 0
fake := oidctest.NewFakeIDP(t,
oidctest.WithServing(),
oidctest.WithDefaultExpire(time.Minute*8),
oidctest.WithRefresh(func(email string) error {
tokenRefreshCount++
return nil
}),
)
cfg := fake.OIDCConfig(t, nil)
// Fetch a valid token from the fake OIDC provider
token, err := fake.GenerateAuthenticatedToken(jwt.MapClaims{
"email": "user@unauthorized.com",
"email_verified": true,
"sub": uuid.NewString(),
})
require.NoError(t, err)
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: "foo",
OAuthAccessToken: token.AccessToken,
OAuthRefreshToken: token.RefreshToken,
// The oauth expiry does not really matter, since each test will manually control
// this value.
OAuthExpiry: dbtime.Now().Add(time.Hour),
})
setLinkExpiration := func(t *testing.T, exp time.Time) database.UserLink {
ctx := testutil.Context(t, testutil.WaitShort)
links, err := db.GetUserLinksByUserID(ctx, user.ID)
require.NoError(t, err)
require.Len(t, links, 1)
link := links[0]
newLink, err := db.UpdateUserLink(ctx, database.UpdateUserLinkParams{
OAuthAccessToken: link.OAuthAccessToken,
OAuthAccessTokenKeyID: link.OAuthAccessTokenKeyID,
OAuthRefreshToken: link.OAuthRefreshToken,
OAuthRefreshTokenKeyID: link.OAuthRefreshTokenKeyID,
OAuthExpiry: exp,
Claims: link.Claims,
UserID: link.UserID,
LoginType: link.LoginType,
})
require.NoError(t, err)
return newLink
}
for _, c := range []struct {
name string
// expires is a function to return a more up to date "now".
// Because the oauth library is calling `time.Now()`, we cannot use
// mocked clocks.
expires func() time.Time
refreshExpected bool
}{
{
name: "ZeroExpiry",
expires: func() time.Time { return time.Time{} },
refreshExpected: false,
},
{
name: "LongExpired",
expires: func() time.Time { return dbtime.Now().Add(-time.Hour) },
refreshExpected: true,
},
{
name: "EdgeExpired",
expires: func() time.Time { return dbtime.Now().Add(-time.Minute * 10) },
refreshExpected: true,
},
{
name: "RecentExpired",
expires: func() time.Time { return dbtime.Now().Add(-time.Second * -1) },
refreshExpected: true,
},
{
name: "Future",
expires: func() time.Time { return dbtime.Now().Add(time.Hour) },
refreshExpected: false,
},
{
name: "FutureWithinRefreshWindow",
expires: func() time.Time { return dbtime.Now().Add(time.Minute * 8) },
refreshExpected: true,
},
} {
t.Run(c.name, func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
oldLink := setLinkExpiration(t, c.expires())
tokenRefreshCount = 0
_, err := provisionerdserver.ObtainOIDCAccessToken(ctx, testutil.Logger(t), db, cfg, user.ID)
require.NoError(t, err)
links, err := db.GetUserLinksByUserID(ctx, user.ID)
require.NoError(t, err)
require.Len(t, links, 1)
newLink := links[0]
if c.refreshExpected {
require.Equal(t, 1, tokenRefreshCount)
require.NotEqual(t, oldLink.OAuthAccessToken, newLink.OAuthAccessToken)
require.NotEqual(t, oldLink.OAuthRefreshToken, newLink.OAuthRefreshToken)
} else {
require.Equal(t, 0, tokenRefreshCount)
require.Equal(t, oldLink.OAuthAccessToken, newLink.OAuthAccessToken)
require.Equal(t, oldLink.OAuthRefreshToken, newLink.OAuthRefreshToken)
}
})
}
}
func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] {
poitr := &atomic.Pointer[schedule.TemplateScheduleStore]{}
store := schedule.NewAGPLTemplateScheduleStore()
+25
View File
@@ -122,6 +122,31 @@ deployment. They will always be available from the agent.
| `coder_aibridgeproxyd_inflight_mitm_requests` | gauge | Number of MITM requests currently being processed. | `provider` |
| `coder_aibridgeproxyd_mitm_requests_total` | counter | Total number of MITM requests handled by the proxy. | `provider` |
| `coder_aibridgeproxyd_mitm_responses_total` | counter | Total number of MITM responses by HTTP status code class. | `code` `provider` |
| `coder_derp_server_accepts_total` | counter | Total DERP connections accepted. | |
| `coder_derp_server_average_queue_duration_ms` | gauge | Average queue duration in milliseconds. | |
| `coder_derp_server_bytes_received_total` | counter | Total bytes received. | |
| `coder_derp_server_bytes_sent_total` | counter | Total bytes sent. | |
| `coder_derp_server_clients` | gauge | Total clients (local + remote). | |
| `coder_derp_server_clients_local` | gauge | Local clients. | |
| `coder_derp_server_clients_remote` | gauge | Remote (mesh) clients. | |
| `coder_derp_server_connections` | gauge | Current DERP connections. | |
| `coder_derp_server_got_ping_total` | counter | Total pings received. | |
| `coder_derp_server_home_connections` | gauge | Current home DERP connections. | |
| `coder_derp_server_home_moves_in_total` | counter | Total home moves in. | |
| `coder_derp_server_home_moves_out_total` | counter | Total home moves out. | |
| `coder_derp_server_packets_dropped_reason_total` | counter | Packets dropped by reason. | `reason` |
| `coder_derp_server_packets_dropped_total` | counter | Total packets dropped. | |
| `coder_derp_server_packets_dropped_type_total` | counter | Packets dropped by type. | `type` |
| `coder_derp_server_packets_forwarded_in_total` | counter | Total packets forwarded in from mesh peers. | |
| `coder_derp_server_packets_forwarded_out_total` | counter | Total packets forwarded out to mesh peers. | |
| `coder_derp_server_packets_received_kind_total` | counter | Packets received by kind. | `kind` |
| `coder_derp_server_packets_received_total` | counter | Total packets received. | |
| `coder_derp_server_packets_sent_total` | counter | Total packets sent. | |
| `coder_derp_server_peer_gone_disconnected_total` | counter | Total peer gone (disconnected) frames sent. | |
| `coder_derp_server_peer_gone_not_here_total` | counter | Total peer gone (not here) frames sent. | |
| `coder_derp_server_sent_pong_total` | counter | Total pongs sent. | |
| `coder_derp_server_unknown_frames_total` | counter | Total unknown frames received. | |
| `coder_derp_server_watchers` | gauge | Current watchers. | |
| `coder_pubsub_connected` | gauge | Whether we are connected (1) or not connected (0) to postgres | |
| `coder_pubsub_current_events` | gauge | The current number of pubsub event channels listened for | |
| `coder_pubsub_current_subscribers` | gauge | The current number of active pubsub subscribers | |
+18
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"errors"
"expvar"
"fmt"
"net/http"
"net/url"
@@ -42,8 +43,14 @@ import (
sharedhttpmw "github.com/coder/coder/v2/httpmw"
"github.com/coder/coder/v2/site"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/derpmetrics"
)
// expDERPOnce guards the global expvar.Publish call for the DERP server.
// expvar panics on duplicate registration, and tests may create multiple
// servers in the same process.
var expDERPOnce sync.Once
type Options struct {
Logger slog.Logger
Experiments codersdk.Experiments
@@ -196,6 +203,17 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
return nil, xerrors.Errorf("create DERP mesh tls config: %w", err)
}
derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(opts.Logger.Named("net.derp")))
// Publish DERP stats to expvar, available via the pprof
// debug server (--pprof-enable) at /debug/vars. This avoids
// exposing expvar on the public HTTP router.
expDERPOnce.Do(func() {
if expvar.Get("derp") == nil {
expvar.Publish("derp", derpServer.ExpVar())
}
})
if opts.PrometheusRegistry != nil {
opts.PrometheusRegistry.MustRegister(derpmetrics.NewDERPExpvarCollector(derpServer))
}
ctx, cancel := context.WithCancel(context.Background())
+52
View File
@@ -1223,3 +1223,55 @@ func createProxyReplicas(ctx context.Context, t *testing.T, opts *createProxyRep
return proxies
}
func TestWorkspaceProxyDERPMetrics(t *testing.T) {
t.Parallel()
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{"*"}
client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: deploymentValues,
AppHostname: "*.primary.test.coder.com",
IncludeProvisionerDaemon: true,
RealIPConfig: &httpmw.RealIPConfig{
TrustedOrigins: []*net.IPNet{{
IP: net.ParseIP("127.0.0.1"),
Mask: net.CIDRMask(8, 32),
}},
TrustedHeaders: []string{
"CF-Connecting-IP",
},
},
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceProxy: 1,
},
},
})
t.Cleanup(func() {
_ = closer.Close()
})
proxy := coderdenttest.NewWorkspaceProxyReplica(t, api, client, &coderdenttest.ProxyOptions{
Name: "metrics-test-proxy",
})
// Gather metrics from the wsproxy's Prometheus registry.
metrics, err := proxy.PrometheusRegistry.Gather()
require.NoError(t, err)
names := make(map[string]struct{})
for _, m := range metrics {
names[m.GetName()] = struct{}{}
}
assert.Contains(t, names, "coder_derp_server_connections",
"expected coder_derp_server_connections to be registered")
assert.Contains(t, names, "coder_derp_server_bytes_received_total",
"expected coder_derp_server_bytes_received_total to be registered")
assert.Contains(t, names, "coder_derp_server_packets_dropped_reason_total",
"expected coder_derp_server_packets_dropped_reason_total to be registered")
}
+16 -11
View File
@@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202
// There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here:
// https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e
replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260306035934-af5c6fc52433
// This is replaced to include
// 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25
@@ -107,7 +107,7 @@ require (
github.com/coder/wgtunnel v0.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/creack/pty v1.1.21
github.com/creack/pty v1.1.24
github.com/dave/dst v0.27.2
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/dblohm7/wingoes v0.0.0-20240820181039-f2b84150679e
@@ -277,13 +277,12 @@ require (
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/coreos/go-iptables v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/cli v28.3.2+incompatible // indirect
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect
github.com/dustin/go-humanize v1.0.1
@@ -324,7 +323,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-cty v1.5.0 // indirect
@@ -439,7 +438,7 @@ require (
go.opentelemetry.io/contrib v1.19.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
@@ -473,7 +472,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v1.0.6
github.com/coder/aibridge v1.0.9
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v0.8.3
github.com/coder/preview v1.0.4
@@ -523,10 +522,13 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/coder/paralleltestctx v0.0.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/esiqveland/notify v0.13.3 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
@@ -544,6 +546,8 @@ require (
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/moby/moby/api v1.54.0 // indirect
github.com/moby/moby/client v0.3.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/openai/openai-go v1.12.0 // indirect
@@ -573,6 +577,7 @@ require (
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
google.golang.org/genai v1.12.0 // indirect
+26 -20
View File
@@ -909,8 +909,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -928,8 +928,8 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/T
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
github.com/coder/aibridge v1.0.6 h1:RVcJCutgWAd8MOxNI5MNVBl+ttqShVsmMQvUAkfuU9Q=
github.com/coder/aibridge v1.0.6/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
github.com/coder/aibridge v1.0.9 h1:vZHpIi/6lo1yKv8cVkc/vwUR6EQwXs3Y0x3HP1tjqGM=
github.com/coder/aibridge v1.0.9/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/boundary v0.8.3 h1:QOb5WYKieRH/gwyUgofC9FDHSSJHpdw1jTrB5zsHovA=
@@ -963,8 +963,8 @@ github.com/coder/serpent v0.14.0 h1:g7vt2zBMp3nWyAvyhvQduaI53Ku65U3wITMi01+/8pU=
github.com/coder/serpent v0.14.0/go.mod h1:7OIvFBYMd+OqarMy5einBl8AtRr8LliopVU7pyrwucY=
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw=
github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ=
github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e h1:9RKGKzGLHtTvVBQublzDGtCtal3cXP13diCHoAIGPeI=
github.com/coder/tailscale v1.1.1-0.20250829055706-6eafe0f9199e/go.mod h1:jU9T1vEs+DOs8NtGp1F2PT0/TOGVwtg/JCCKYRgvMOs=
github.com/coder/tailscale v1.1.1-0.20260306035934-af5c6fc52433 h1:NxqWSEZFuCeIR/N7lZ9cx+434urbNvrrA7ZyNPTwnmc=
github.com/coder/tailscale v1.1.1-0.20260306035934-af5c6fc52433/go.mod h1:q+R4UL4pPb0CpaSNVUTDsg0kZeL/OlqjRNO9XbJxU5g=
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0=
github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI=
github.com/coder/terraform-provider-coder/v2 v2.13.1 h1:dtPaJUvueFm+XwBPUMWQCc5Z1QUQBW4B4RNyzX4h4y8=
@@ -999,8 +999,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48=
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
@@ -1035,12 +1035,12 @@ github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v28.3.2+incompatible h1:mOt9fcLE7zaACbxW1GeS65RI67wIJrTnqS3hP2huFsY=
github.com/docker/cli v28.3.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
@@ -1078,16 +1078,16 @@ github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJ
github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo=
github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w=
github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
@@ -1393,8 +1393,8 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w7
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -1627,6 +1627,10 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/moby/api v1.54.0 h1:7kbUgyiKcoBhm0UrWbdrMs7RX8dnwzURKVbZGy2GnL0=
github.com/moby/moby/api v1.54.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.3.0 h1:UUGL5okry+Aomj3WhGt9Aigl3ZOxZGqR7XPo+RLPlKs=
github.com/moby/moby/client v0.3.0/go.mod h1:HJgFbJRvogDQjbM8fqc1MCEm4mIAGMLjXbgwoZp6jCQ=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -2066,8 +2070,8 @@ go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4Etq
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
@@ -2889,6 +2893,8 @@ modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k=
mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+75
View File
@@ -1,3 +1,78 @@
# HELP coder_derp_server_accepts_total Total DERP connections accepted.
# TYPE coder_derp_server_accepts_total counter
coder_derp_server_accepts_total 0
# HELP coder_derp_server_average_queue_duration_ms Average queue duration in milliseconds.
# TYPE coder_derp_server_average_queue_duration_ms gauge
coder_derp_server_average_queue_duration_ms 0
# HELP coder_derp_server_bytes_received_total Total bytes received.
# TYPE coder_derp_server_bytes_received_total counter
coder_derp_server_bytes_received_total 0
# HELP coder_derp_server_bytes_sent_total Total bytes sent.
# TYPE coder_derp_server_bytes_sent_total counter
coder_derp_server_bytes_sent_total 0
# HELP coder_derp_server_clients Total clients (local + remote).
# TYPE coder_derp_server_clients gauge
coder_derp_server_clients 0
# HELP coder_derp_server_clients_local Local clients.
# TYPE coder_derp_server_clients_local gauge
coder_derp_server_clients_local 0
# HELP coder_derp_server_clients_remote Remote (mesh) clients.
# TYPE coder_derp_server_clients_remote gauge
coder_derp_server_clients_remote 0
# HELP coder_derp_server_connections Current DERP connections.
# TYPE coder_derp_server_connections gauge
coder_derp_server_connections 0
# HELP coder_derp_server_got_ping_total Total pings received.
# TYPE coder_derp_server_got_ping_total counter
coder_derp_server_got_ping_total 0
# HELP coder_derp_server_home_connections Current home DERP connections.
# TYPE coder_derp_server_home_connections gauge
coder_derp_server_home_connections 0
# HELP coder_derp_server_home_moves_in_total Total home moves in.
# TYPE coder_derp_server_home_moves_in_total counter
coder_derp_server_home_moves_in_total 0
# HELP coder_derp_server_home_moves_out_total Total home moves out.
# TYPE coder_derp_server_home_moves_out_total counter
coder_derp_server_home_moves_out_total 0
# HELP coder_derp_server_packets_dropped_reason_total Packets dropped by reason.
# TYPE coder_derp_server_packets_dropped_reason_total counter
coder_derp_server_packets_dropped_reason_total{reason=""} 0
# HELP coder_derp_server_packets_dropped_total Total packets dropped.
# TYPE coder_derp_server_packets_dropped_total counter
coder_derp_server_packets_dropped_total 0
# HELP coder_derp_server_packets_dropped_type_total Packets dropped by type.
# TYPE coder_derp_server_packets_dropped_type_total counter
coder_derp_server_packets_dropped_type_total{type=""} 0
# HELP coder_derp_server_packets_forwarded_in_total Total packets forwarded in from mesh peers.
# TYPE coder_derp_server_packets_forwarded_in_total counter
coder_derp_server_packets_forwarded_in_total 0
# HELP coder_derp_server_packets_forwarded_out_total Total packets forwarded out to mesh peers.
# TYPE coder_derp_server_packets_forwarded_out_total counter
coder_derp_server_packets_forwarded_out_total 0
# HELP coder_derp_server_packets_received_kind_total Packets received by kind.
# TYPE coder_derp_server_packets_received_kind_total counter
coder_derp_server_packets_received_kind_total{kind=""} 0
# HELP coder_derp_server_packets_received_total Total packets received.
# TYPE coder_derp_server_packets_received_total counter
coder_derp_server_packets_received_total 0
# HELP coder_derp_server_packets_sent_total Total packets sent.
# TYPE coder_derp_server_packets_sent_total counter
coder_derp_server_packets_sent_total 0
# HELP coder_derp_server_peer_gone_disconnected_total Total peer gone (disconnected) frames sent.
# TYPE coder_derp_server_peer_gone_disconnected_total counter
coder_derp_server_peer_gone_disconnected_total 0
# HELP coder_derp_server_peer_gone_not_here_total Total peer gone (not here) frames sent.
# TYPE coder_derp_server_peer_gone_not_here_total counter
coder_derp_server_peer_gone_not_here_total 0
# HELP coder_derp_server_sent_pong_total Total pongs sent.
# TYPE coder_derp_server_sent_pong_total counter
coder_derp_server_sent_pong_total 0
# HELP coder_derp_server_unknown_frames_total Total unknown frames received.
# TYPE coder_derp_server_unknown_frames_total counter
coder_derp_server_unknown_frames_total 0
# HELP coder_derp_server_watchers Current watchers.
# TYPE coder_derp_server_watchers gauge
coder_derp_server_watchers 0
# HELP coder_pubsub_connected Whether we are connected (1) or not connected (0) to postgres
# TYPE coder_pubsub_connected gauge
coder_pubsub_connected 0
+1
View File
@@ -30,6 +30,7 @@ var scanDirs = []string{
"coderd",
"enterprise",
"provisionerd",
"tailnet",
}
// skipPaths lists files that should be excluded from scanning. Their metrics
+1 -1
View File
@@ -1429,7 +1429,7 @@ func (c *Controller) Run(ctx context.Context) {
tailnetClients, err := c.Dialer.Dial(c.ctx, c.ResumeTokenCtrl)
if err != nil {
if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
if c.ctx.Err() != nil {
return
}
+103
View File
@@ -1075,6 +1075,84 @@ func TestController_Disconnects(t *testing.T) {
_ = testutil.TryReceive(testCtx, t, uut.Closed())
}
func TestController_RetriesWrappedDeadlineExceeded(t *testing.T) {
t.Parallel()
testCtx := testutil.Context(t, testutil.WaitShort)
ctx, cancel := context.WithCancel(testCtx)
defer cancel()
logger := testutil.Logger(t)
dialer := &scriptedDialer{
attempts: make(chan int, 10),
dialFn: func(ctx context.Context, attempt int) (tailnet.ControlProtocolClients, error) {
if attempt == 1 {
return tailnet.ControlProtocolClients{}, &net.OpError{
Op: "dial",
Net: "tcp",
Err: context.DeadlineExceeded,
}
}
<-ctx.Done()
return tailnet.ControlProtocolClients{}, ctx.Err()
},
}
uut := tailnet.NewController(logger.Named("ctrl"), dialer)
uut.Run(ctx)
require.Equal(t, 1, testutil.TryReceive(testCtx, t, dialer.attempts))
require.Equal(t, 2, testutil.TryReceive(testCtx, t, dialer.attempts))
select {
case <-uut.Closed():
t.Fatal("controller exited after wrapped deadline exceeded")
default:
}
cancel()
_ = testutil.TryReceive(testCtx, t, uut.Closed())
}
func TestController_DoesNotRedialAfterCancel(t *testing.T) {
t.Parallel()
testCtx := testutil.Context(t, testutil.WaitShort)
ctx, cancel := context.WithCancel(testCtx)
logger := testutil.Logger(t)
fClient := newFakeWorkspaceUpdateClient(testCtx, t)
dialer := &scriptedDialer{
attempts: make(chan int, 10),
dialFn: func(_ context.Context, _ int) (tailnet.ControlProtocolClients, error) {
return tailnet.ControlProtocolClients{
WorkspaceUpdates: fClient,
Closer: fakeCloser{},
}, nil
},
}
fCtrl := newFakeUpdatesController(testCtx, t)
uut := tailnet.NewController(logger.Named("ctrl"), dialer)
uut.WorkspaceUpdatesCtrl = fCtrl
uut.Run(ctx)
require.Equal(t, 1, testutil.TryReceive(testCtx, t, dialer.attempts))
call := testutil.TryReceive(testCtx, t, fCtrl.calls)
require.Equal(t, fClient, call.client)
testutil.RequireSend[tailnet.CloserWaiter](testCtx, t, call.resp, newFakeCloserWaiter())
cancel()
closeCall := testutil.TryReceive(testCtx, t, fClient.close)
testutil.RequireSend(testCtx, t, closeCall, nil)
_ = testutil.TryReceive(testCtx, t, uut.Closed())
select {
case attempt := <-dialer.attempts:
t.Fatalf("unexpected redial attempt after cancel: %d", attempt)
default:
}
}
func TestController_TelemetrySuccess(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
@@ -2070,6 +2148,31 @@ func newFakeCloserWaiter() *fakeCloserWaiter {
}
}
type scriptedDialer struct {
attempts chan int
dialFn func(context.Context, int) (tailnet.ControlProtocolClients, error)
mu sync.Mutex
attemptN int
}
func (d *scriptedDialer) Dial(ctx context.Context, _ tailnet.ResumeTokenController) (tailnet.ControlProtocolClients, error) {
d.mu.Lock()
d.attemptN++
attempt := d.attemptN
d.mu.Unlock()
if d.attempts != nil {
select {
case d.attempts <- attempt:
case <-ctx.Done():
return tailnet.ControlProtocolClients{}, ctx.Err()
}
}
return d.dialFn(ctx, attempt)
}
type fakeWorkspaceUpdatesDialer struct {
client tailnet.WorkspaceUpdatesClient
}
+214
View File
@@ -0,0 +1,214 @@
package derpmetrics
import (
"expvar"
"strconv"
"github.com/prometheus/client_golang/prometheus"
"tailscale.com/derp"
)
// DERPExpvarCollector exports a DERP server's expvar stats as
// properly typed Prometheus metrics.
type DERPExpvarCollector struct {
server *derp.Server
// Counters.
accepts *prometheus.Desc
bytesReceived *prometheus.Desc
bytesSent *prometheus.Desc
packetsReceived *prometheus.Desc
packetsSent *prometheus.Desc
packetsDropped *prometheus.Desc
packetsForwardedIn *prometheus.Desc
packetsForwardedOut *prometheus.Desc
homeMovesIn *prometheus.Desc
homeMovesOut *prometheus.Desc
gotPing *prometheus.Desc
sentPong *prometheus.Desc
peerGoneDisconnected *prometheus.Desc
peerGoneNotHere *prometheus.Desc
unknownFrames *prometheus.Desc
// Labeled counters.
packetsDroppedByReason *prometheus.Desc
packetsDroppedByType *prometheus.Desc
packetsReceivedByKind *prometheus.Desc
// Gauges.
connections *prometheus.Desc
homeConnections *prometheus.Desc
clientsTotal *prometheus.Desc
clientsLocal *prometheus.Desc
clientsRemote *prometheus.Desc
watchers *prometheus.Desc
avgQueueDurMS *prometheus.Desc
}
// NewDERPExpvarCollector creates a Prometheus collector that reads
// stats from a DERP server's expvar on each scrape.
func NewDERPExpvarCollector(server *derp.Server) *DERPExpvarCollector {
return &DERPExpvarCollector{
server: server,
accepts: prometheus.NewDesc("coder_derp_server_accepts_total", "Total DERP connections accepted.", nil, nil),
bytesReceived: prometheus.NewDesc("coder_derp_server_bytes_received_total", "Total bytes received.", nil, nil),
bytesSent: prometheus.NewDesc("coder_derp_server_bytes_sent_total", "Total bytes sent.", nil, nil),
packetsReceived: prometheus.NewDesc("coder_derp_server_packets_received_total", "Total packets received.", nil, nil),
packetsSent: prometheus.NewDesc("coder_derp_server_packets_sent_total", "Total packets sent.", nil, nil),
packetsDropped: prometheus.NewDesc("coder_derp_server_packets_dropped_total", "Total packets dropped.", nil, nil),
packetsForwardedIn: prometheus.NewDesc("coder_derp_server_packets_forwarded_in_total", "Total packets forwarded in from mesh peers.", nil, nil),
packetsForwardedOut: prometheus.NewDesc("coder_derp_server_packets_forwarded_out_total", "Total packets forwarded out to mesh peers.", nil, nil),
homeMovesIn: prometheus.NewDesc("coder_derp_server_home_moves_in_total", "Total home moves in.", nil, nil),
homeMovesOut: prometheus.NewDesc("coder_derp_server_home_moves_out_total", "Total home moves out.", nil, nil),
gotPing: prometheus.NewDesc("coder_derp_server_got_ping_total", "Total pings received.", nil, nil),
sentPong: prometheus.NewDesc("coder_derp_server_sent_pong_total", "Total pongs sent.", nil, nil),
peerGoneDisconnected: prometheus.NewDesc("coder_derp_server_peer_gone_disconnected_total", "Total peer gone (disconnected) frames sent.", nil, nil),
peerGoneNotHere: prometheus.NewDesc("coder_derp_server_peer_gone_not_here_total", "Total peer gone (not here) frames sent.", nil, nil),
unknownFrames: prometheus.NewDesc("coder_derp_server_unknown_frames_total", "Total unknown frames received.", nil, nil),
packetsDroppedByReason: prometheus.NewDesc("coder_derp_server_packets_dropped_reason_total", "Packets dropped by reason.", []string{"reason"}, nil),
packetsDroppedByType: prometheus.NewDesc("coder_derp_server_packets_dropped_type_total", "Packets dropped by type.", []string{"type"}, nil),
packetsReceivedByKind: prometheus.NewDesc("coder_derp_server_packets_received_kind_total", "Packets received by kind.", []string{"kind"}, nil),
connections: prometheus.NewDesc("coder_derp_server_connections", "Current DERP connections.", nil, nil),
homeConnections: prometheus.NewDesc("coder_derp_server_home_connections", "Current home DERP connections.", nil, nil),
clientsTotal: prometheus.NewDesc("coder_derp_server_clients", "Total clients (local + remote).", nil, nil),
clientsLocal: prometheus.NewDesc("coder_derp_server_clients_local", "Local clients.", nil, nil),
clientsRemote: prometheus.NewDesc("coder_derp_server_clients_remote", "Remote (mesh) clients.", nil, nil),
watchers: prometheus.NewDesc("coder_derp_server_watchers", "Current watchers.", nil, nil),
avgQueueDurMS: prometheus.NewDesc("coder_derp_server_average_queue_duration_ms", "Average queue duration in milliseconds.", nil, nil),
}
}
func (c *DERPExpvarCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.accepts
ch <- c.bytesReceived
ch <- c.bytesSent
ch <- c.packetsReceived
ch <- c.packetsSent
ch <- c.packetsDropped
ch <- c.packetsForwardedIn
ch <- c.packetsForwardedOut
ch <- c.homeMovesIn
ch <- c.homeMovesOut
ch <- c.gotPing
ch <- c.sentPong
ch <- c.peerGoneDisconnected
ch <- c.peerGoneNotHere
ch <- c.unknownFrames
ch <- c.packetsDroppedByReason
ch <- c.packetsDroppedByType
ch <- c.packetsReceivedByKind
ch <- c.connections
ch <- c.homeConnections
ch <- c.clientsTotal
ch <- c.clientsLocal
ch <- c.clientsRemote
ch <- c.watchers
ch <- c.avgQueueDurMS
}
// Collect reads the DERP server's expvar stats and emits them as
// Prometheus metrics. Called on each /metrics scrape.
func (c *DERPExpvarCollector) Collect(ch chan<- prometheus.Metric) {
vars, ok := c.server.ExpVar().(interface {
Do(func(expvar.KeyValue))
})
if !ok {
return
}
vars.Do(func(kv expvar.KeyValue) {
switch kv.Key {
case "accepts":
emitCounter(ch, c.accepts, kv.Value)
case "bytes_received":
emitCounter(ch, c.bytesReceived, kv.Value)
case "bytes_sent":
emitCounter(ch, c.bytesSent, kv.Value)
case "packets_received":
emitCounter(ch, c.packetsReceived, kv.Value)
case "packets_sent":
emitCounter(ch, c.packetsSent, kv.Value)
case "packets_dropped":
emitCounter(ch, c.packetsDropped, kv.Value)
case "packets_forwarded_in":
emitCounter(ch, c.packetsForwardedIn, kv.Value)
case "packets_forwarded_out":
emitCounter(ch, c.packetsForwardedOut, kv.Value)
case "home_moves_in":
emitCounter(ch, c.homeMovesIn, kv.Value)
case "home_moves_out":
emitCounter(ch, c.homeMovesOut, kv.Value)
case "got_ping":
emitCounter(ch, c.gotPing, kv.Value)
case "sent_pong":
emitCounter(ch, c.sentPong, kv.Value)
case "peer_gone_disconnected_frames":
emitCounter(ch, c.peerGoneDisconnected, kv.Value)
case "peer_gone_not_here_frames":
emitCounter(ch, c.peerGoneNotHere, kv.Value)
case "unknown_frames":
emitCounter(ch, c.unknownFrames, kv.Value)
case "counter_packets_dropped_reason":
emitLabeledCounters(ch, c.packetsDroppedByReason, kv.Value)
case "counter_packets_dropped_type":
emitLabeledCounters(ch, c.packetsDroppedByType, kv.Value)
case "counter_packets_received_kind":
emitLabeledCounters(ch, c.packetsReceivedByKind, kv.Value)
case "gauge_current_connections":
emitGauge(ch, c.connections, kv.Value)
case "gauge_current_home_connections":
emitGauge(ch, c.homeConnections, kv.Value)
case "gauge_clients_total":
emitGauge(ch, c.clientsTotal, kv.Value)
case "gauge_clients_local":
emitGauge(ch, c.clientsLocal, kv.Value)
case "gauge_clients_remote":
emitGauge(ch, c.clientsRemote, kv.Value)
case "gauge_watchers":
emitGauge(ch, c.watchers, kv.Value)
case "average_queue_duration_ms":
emitGauge(ch, c.avgQueueDurMS, kv.Value)
}
})
}
func emitCounter(ch chan<- prometheus.Metric, desc *prometheus.Desc, v expvar.Var) {
if f, ok := parseExpvarFloat(v); ok {
ch <- prometheus.MustNewConstMetric(desc, prometheus.CounterValue, f)
}
}
func emitGauge(ch chan<- prometheus.Metric, desc *prometheus.Desc, v expvar.Var) {
if f, ok := parseExpvarFloat(v); ok {
ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, f)
}
}
func emitLabeledCounters(ch chan<- prometheus.Metric, desc *prometheus.Desc, v expvar.Var) {
sub, ok := v.(interface{ Do(func(expvar.KeyValue)) })
if !ok {
return
}
sub.Do(func(kv expvar.KeyValue) {
if f, ok := parseExpvarFloat(kv.Value); ok {
ch <- prometheus.MustNewConstMetric(desc, prometheus.CounterValue, f, kv.Key)
}
})
}
func parseExpvarFloat(v expvar.Var) (float64, bool) {
switch val := v.(type) {
case *expvar.Int:
return float64(val.Value()), true
case *expvar.Float:
return val.Value(), true
default:
f, err := strconv.ParseFloat(v.String(), 64)
return f, err == nil
}
}
+177
View File
@@ -0,0 +1,177 @@
package derpmetrics_test
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
ptestutil "github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/derp"
"tailscale.com/types/key"
"github.com/coder/coder/v2/tailnet/derpmetrics"
)
func TestDERPExpvarCollector(t *testing.T) {
t.Parallel()
t.Run("RegistersAndCollects", func(t *testing.T) {
t.Parallel()
server := derp.NewServer(key.NewNode(), func(format string, args ...any) {})
defer server.Close()
reg := prometheus.NewRegistry()
collector := derpmetrics.NewDERPExpvarCollector(server)
require.NoError(t, reg.Register(collector))
// Verify we can gather without error.
metrics, err := reg.Gather()
require.NoError(t, err)
require.NotEmpty(t, metrics, "expected at least one metric family")
// Verify expected metric names are present.
names := make(map[string]struct{})
for _, m := range metrics {
names[m.GetName()] = struct{}{}
}
expectedCounters := []string{
"coder_derp_server_accepts_total",
"coder_derp_server_bytes_received_total",
"coder_derp_server_bytes_sent_total",
"coder_derp_server_packets_received_total",
"coder_derp_server_packets_sent_total",
"coder_derp_server_packets_dropped_total",
"coder_derp_server_packets_forwarded_in_total",
"coder_derp_server_packets_forwarded_out_total",
"coder_derp_server_home_moves_in_total",
"coder_derp_server_home_moves_out_total",
"coder_derp_server_got_ping_total",
"coder_derp_server_sent_pong_total",
"coder_derp_server_peer_gone_disconnected_total",
"coder_derp_server_peer_gone_not_here_total",
"coder_derp_server_unknown_frames_total",
}
expectedGauges := []string{
"coder_derp_server_connections",
"coder_derp_server_home_connections",
"coder_derp_server_clients",
"coder_derp_server_clients_local",
"coder_derp_server_clients_remote",
"coder_derp_server_watchers",
"coder_derp_server_average_queue_duration_ms",
}
expectedLabeled := []string{
"coder_derp_server_packets_dropped_reason_total",
"coder_derp_server_packets_dropped_type_total",
"coder_derp_server_packets_received_kind_total",
}
for _, name := range expectedCounters {
assert.Contains(t, names, name, "missing counter %s", name)
}
for _, name := range expectedGauges {
assert.Contains(t, names, name, "missing gauge %s", name)
}
for _, name := range expectedLabeled {
assert.Contains(t, names, name, "missing labeled counter %s", name)
}
})
t.Run("CounterTypes", func(t *testing.T) {
t.Parallel()
server := derp.NewServer(key.NewNode(), func(format string, args ...any) {})
defer server.Close()
reg := prometheus.NewRegistry()
collector := derpmetrics.NewDERPExpvarCollector(server)
require.NoError(t, reg.Register(collector))
// Counters should report as counter type.
count := ptestutil.CollectAndCount(collector)
assert.Greater(t, count, 0, "expected metrics to be collected")
// Verify a known counter starts at zero.
metrics, err := reg.Gather()
require.NoError(t, err)
for _, m := range metrics {
if m.GetName() == "coder_derp_server_bytes_received_total" {
require.Len(t, m.GetMetric(), 1)
assert.Equal(t, float64(0), m.GetMetric()[0].GetCounter().GetValue())
return
}
}
t.Fatal("coder_derp_server_bytes_received_total not found")
})
t.Run("GaugeTypes", func(t *testing.T) {
t.Parallel()
server := derp.NewServer(key.NewNode(), func(format string, args ...any) {})
defer server.Close()
reg := prometheus.NewRegistry()
collector := derpmetrics.NewDERPExpvarCollector(server)
require.NoError(t, reg.Register(collector))
metrics, err := reg.Gather()
require.NoError(t, err)
for _, m := range metrics {
if m.GetName() == "coder_derp_server_connections" {
require.Len(t, m.GetMetric(), 1)
// Gauge type check — GetGauge should be non-nil.
assert.NotNil(t, m.GetMetric()[0].GetGauge())
assert.Equal(t, float64(0), m.GetMetric()[0].GetGauge().GetValue())
return
}
}
t.Fatal("coder_derp_server_connections not found")
})
t.Run("LabeledCounters", func(t *testing.T) {
t.Parallel()
server := derp.NewServer(key.NewNode(), func(format string, args ...any) {})
defer server.Close()
reg := prometheus.NewRegistry()
collector := derpmetrics.NewDERPExpvarCollector(server)
require.NoError(t, reg.Register(collector))
metrics, err := reg.Gather()
require.NoError(t, err)
for _, m := range metrics {
if m.GetName() == "coder_derp_server_packets_dropped_reason_total" {
// Should have labeled sub-metrics (one per reason).
require.NotEmpty(t, m.GetMetric(), "expected labeled metrics for drop reasons")
// Each metric should have a "reason" label.
for _, metric := range m.GetMetric() {
labels := metric.GetLabel()
require.Len(t, labels, 1)
assert.Equal(t, "reason", labels[0].GetName())
}
return
}
}
t.Fatal("coder_derp_server_packets_dropped_reason_total not found")
})
t.Run("NoDuplicateRegistration", func(t *testing.T) {
t.Parallel()
server := derp.NewServer(key.NewNode(), func(format string, args ...any) {})
defer server.Close()
reg := prometheus.NewRegistry()
c1 := derpmetrics.NewDERPExpvarCollector(server)
require.NoError(t, reg.Register(c1))
c2 := derpmetrics.NewDERPExpvarCollector(server)
err := reg.Register(c2)
assert.Error(t, err, "registering a second collector should fail")
})
}