diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 57dee3f6d16..3812ad78908 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -23353,7 +23353,7 @@ const docTemplate = `{ "ExperimentMCPToolSearch": "Defers MCP tool schemas behind a searchable catalog in agent chats.", "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", - "ExperimentOAuth2": "Enables OAuth2 provider functionality.", + "ExperimentOAuth2": "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", "ExperimentWorkspaceCapableLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." @@ -23363,7 +23363,7 @@ const docTemplate = `{ "This should not be taken out of experiments until we have redesigned the feature.", "Sends notifications via SMTP and webhooks following certain events.", "Enables the new workspace usage tracking.", - "Enables OAuth2 provider functionality.", + "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "Enables the MCP HTTP server functionality.", "Defers MCP tool schemas behind a searchable catalog in agent chats.", "Enables publishing workspace build updates to the all builds pubsub channel.", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a8184b49458..245a1aea546 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -21253,7 +21253,7 @@ "ExperimentMCPToolSearch": "Defers MCP tool schemas behind a searchable catalog in agent chats.", "ExperimentNATSPubsub": "Enables embedded NATS pubsub.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", - "ExperimentOAuth2": "Enables OAuth2 provider functionality.", + "ExperimentOAuth2": "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "ExperimentWorkspaceBuildUpdates": "Enables publishing workspace build updates to the all builds pubsub channel.", "ExperimentWorkspaceCapableLicensing": "Counts only users holding the workspace-create permission toward the license seat limit.", "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." @@ -21263,7 +21263,7 @@ "This should not be taken out of experiments until we have redesigned the feature.", "Sends notifications via SMTP and webhooks following certain events.", "Enables the new workspace usage tracking.", - "Enables OAuth2 provider functionality.", + "Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE.", "Enables the MCP HTTP server functionality.", "Defers MCP tool schemas behind a searchable catalog in agent chats.", "Enables publishing workspace build updates to the all builds pubsub channel.", diff --git a/coderd/coderd.go b/coderd/coderd.go index 9f228fefe7b..c427519a579 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2578,15 +2578,38 @@ func (api *API) DERPMap() *tailcfg.DERPMap { return api.BaseDERPMap } +// oauth2ExperimentDeprecatedMessage is logged when a retired oauth2 +// experiment value is still configured. +const oauth2ExperimentDeprecatedMessage = `CODER_EXPERIMENTS contains "oauth2", which is deprecated and has no effect. The OAuth2 provider is now generally available and disabled by default. Set CODER_OAUTH2_PROVIDER_ENABLE=true to enable it. The "oauth2" experiment value will be removed in a future release.` + +// warnOAuth2ExperimentDeprecated limits the deprecation warning to once per +// process. coder server reads the experiment list several times during +// startup, and every read would otherwise repeat the line. +var warnOAuth2ExperimentDeprecated sync.Once + // nolint:revive func ReadExperiments(log slog.Logger, raw []string) codersdk.Experiments { + return parseExperiments(log, raw, &warnOAuth2ExperimentDeprecated) +} + +// parseExperiments takes the warning guard as a parameter so tests can check +// the once-only behavior with their own sync.Once instead of resetting the +// package-level one. +func parseExperiments(log slog.Logger, raw []string, warnOAuth2Once *sync.Once) codersdk.Experiments { exps := make([]codersdk.Experiment, 0, len(raw)) for _, v := range raw { - switch v { + ex := codersdk.Experiment(strings.ToLower(v)) + switch ex { case "*": exps = append(exps, codersdk.ExperimentsSafe...) + case codersdk.ExperimentOAuth2: + // Recognized but inert so the warning can be specific. PLAT-635 + // removes the constant and this branch. Deliberately not + // appended: nothing may observe the experiment as enabled. + warnOAuth2Once.Do(func() { + log.Warn(context.Background(), oauth2ExperimentDeprecatedMessage) + }) default: - ex := codersdk.Experiment(strings.ToLower(v)) if !slice.Contains(codersdk.ExperimentsKnown, ex) { log.Warn(context.Background(), "ignoring unknown experiment", slog.F("experiment", ex)) } else if !slice.Contains(codersdk.ExperimentsSafe, ex) { diff --git a/coderd/experiments_internal_test.go b/coderd/experiments_internal_test.go new file mode 100644 index 00000000000..a7918757504 --- /dev/null +++ b/coderd/experiments_internal_test.go @@ -0,0 +1,72 @@ +package coderd + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/codersdk" +) + +// logRecorder keeps every entry so a test can count log lines. +type logRecorder struct { + mu sync.Mutex + entries []slog.SinkEntry +} + +func (s *logRecorder) LogEntry(_ context.Context, e slog.SinkEntry) { + s.mu.Lock() + defer s.mu.Unlock() + s.entries = append(s.entries, e) +} + +func (*logRecorder) Sync() {} + +func (s *logRecorder) messages(level slog.Level) []string { + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for _, e := range s.entries { + if e.Level == level { + out = append(out, e.Message) + } + } + return out +} + +// count returns how many entries at level carry exactly msg. +func (s *logRecorder) count(level slog.Level, msg string) int { + var n int + for _, m := range s.messages(level) { + if m == msg { + n++ + } + } + return n +} + +func TestReadExperimentsDeprecatedOAuth2(t *testing.T) { + t.Parallel() + + rec := &logRecorder{} + log := slog.Make(rec) + var once sync.Once + raw := []string{string(codersdk.ExperimentOAuth2), string(codersdk.ExperimentMCPServerHTTP)} + + got := parseExperiments(log, raw, &once) + require.Equal(t, codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, got, + "the oauth2 experiment must be dropped, not passed through") + require.Equal(t, 1, rec.count(slog.LevelWarn, oauth2ExperimentDeprecatedMessage)) + require.NotContains(t, rec.messages(slog.LevelWarn), "ignoring unknown experiment", + "oauth2 must be matched before the unknown-experiment branch") + + // A second read in the same process returns the same values and does not + // repeat the deprecation warning. Upper-case input is matched too. + got = parseExperiments(log, []string{"OAuth2", string(codersdk.ExperimentMCPServerHTTP)}, &once) + require.Equal(t, codersdk.Experiments{codersdk.ExperimentMCPServerHTTP}, got) + require.Equal(t, 1, rec.count(slog.LevelWarn, oauth2ExperimentDeprecatedMessage), + "deprecation warning must be logged once per process") +} diff --git a/coderd/experiments_test.go b/coderd/experiments_test.go index 8f5944609ab..fac9fbf9e46 100644 --- a/coderd/experiments_test.go +++ b/coderd/experiments_test.go @@ -99,6 +99,26 @@ func Test_Experiments(t *testing.T) { require.False(t, experiments.Enabled("herebedragons")) }) + t.Run("deprecated oauth2 experiment is dropped", func(t *testing.T) { + t.Parallel() + cfg := coderdtest.DeploymentValues(t) + cfg.Experiments = []string{string(codersdk.ExperimentOAuth2), string(codersdk.ExperimentMCPServerHTTP)} + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: cfg, + }) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + experiments, err := client.Experiments(ctx) + require.NoError(t, err) + // The provider is controlled by CODER_OAUTH2_PROVIDER_ENABLE, so the + // experiment must never be reported as enabled. + require.ElementsMatch(t, []codersdk.Experiment{codersdk.ExperimentMCPServerHTTP}, experiments) + require.False(t, experiments.Enabled(codersdk.ExperimentOAuth2)) + }) + t.Run("Unauthorized", func(t *testing.T) { t.Parallel() cfg := coderdtest.DeploymentValues(t) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7a0a90de921..746e7e35dc0 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5192,7 +5192,7 @@ const ( ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. - ExperimentOAuth2 Experiment = "oauth2" // Enables OAuth2 provider functionality. + ExperimentOAuth2 Experiment = "oauth2" // Deprecated: has no effect; use CODER_OAUTH2_PROVIDER_ENABLE. ExperimentMCPServerHTTP Experiment = "mcp-server-http" // Enables the MCP HTTP server functionality. ExperimentMCPToolSearch Experiment = "mcp-tool-search" // Defers MCP tool schemas behind a searchable catalog in agent chats. ExperimentWorkspaceBuildUpdates Experiment = "workspace-build-updates" // Enables publishing workspace build updates to the all builds pubsub channel.