diff --git a/internal/controller/codermcpserver_controller.go b/internal/controller/codermcpserver_controller.go index cb4a4c94..e4f5bf24 100644 --- a/internal/controller/codermcpserver_controller.go +++ b/internal/controller/codermcpserver_controller.go @@ -218,6 +218,10 @@ func (r *CoderMCPServerReconciler) check( return step, err } values, step, err := readMCPServerCredentials(ctx, r.Client, server) + if step != nil && step.stalled && server.Status.ConfigID != "" { + // Fail closed when consent goes (plan G8). + step, err = r.disableMCPServerWithoutConsent(ctx, sdk, server, org.ID, step) + } if step != nil || err != nil { return step, err } @@ -411,22 +415,28 @@ func readMCPServerCredentials( "Secret %s changed during the reconcile. The controller reads it again.", c.ref.Name), nil } seen[c.ref.Name] = token - // Coder receives every credential inside a JSON string, and - // encoding/json replaces invalid UTF-8 with U+FFFD, so Coder would - // store another value. - if !utf8.ValidString(value.Reveal()) { - return nil, mcpServerStalled(0, credentialsecret.ReasonMalformed, - "%s: the value of key %s in Secret %s is not valid UTF-8.", c.field, c.ref.Key, c.ref.Name), nil - } - if c.headerValue && !isHTTPFieldValue(value.Reveal()) { + if problem := mcpServerValueProblem(value, c.headerValue); problem != "" { return nil, mcpServerStalled(0, credentialsecret.ReasonMalformed, - "%s: the value of key %s in Secret %s is not a valid HTTP header value.", c.field, c.ref.Key, c.ref.Name), nil + "%s: the value of key %s in Secret %s %s.", c.field, c.ref.Key, c.ref.Name, problem), nil } } } return values, nil, nil } +// mcpServerValueProblem says why Coder cannot store value, or returns "". +// Coder receives every credential inside a JSON string, and encoding/json +// replaces invalid UTF-8 with U+FFFD, so Coder would store another value. +func mcpServerValueProblem(value credentialsecret.Value, headerValue bool) string { + switch { + case !utf8.ValidString(value.Reveal()): + return "is not valid UTF-8" + case headerValue && !isHTTPFieldValue(value.Reveal()): + return "is not a valid HTTP header value" + } + return "" +} + // isHTTPFieldValue reports whether every byte of s may appear in an RFC 9110 // field value: HTAB, SP, visible ASCII, or obs-text. CR, LF and NUL may not. func isHTTPFieldValue(s string) bool { diff --git a/internal/controller/codermcpserver_controller_test.go b/internal/controller/codermcpserver_controller_test.go index ea5b1826..099d6bf5 100644 --- a/internal/controller/codermcpserver_controller_test.go +++ b/internal/controller/codermcpserver_controller_test.go @@ -586,6 +586,17 @@ func TestCoderMCPServerWatches(t *testing.T) { }, 30*time.Second, 100*time.Millisecond, reason) } requireReason("Applied") + // A withdrawn consent disables the config in Coder (plan G8), so only a + // spec edit applies it again. + rearm := func(description string) { + t.Helper() + requireReason("DisabledExternally") + got := &coderv1alpha1.CoderMCPServer{} + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(server), got)) + got.Spec.Description = description + require.NoError(t, k8sClient.Update(ctx, got)) + requireReason("Applied") + } require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(tenant), tenant)) tenant.Labels = nil @@ -593,7 +604,7 @@ func TestCoderMCPServerWatches(t *testing.T) { requireReason(credentialsecret.ReasonNotOptedIn) tenant.Labels = map[string]string{credentialsecret.LabelKey: credentialsecret.LabelValue} require.NoError(t, k8sClient.Update(ctx, tenant)) - requireReason("Applied") + rearm("one") env.setOptIn(ctx, t, nil) requireReason("MCPServersNotAllowed") @@ -605,7 +616,7 @@ func TestCoderMCPServerWatches(t *testing.T) { other := createControlPlaneUsing(ctx, t, env.namespace, "license", tenant.Name) requireReason(credentialsecret.ReasonRefused) require.NoError(t, k8sClient.Delete(ctx, &coderv1alpha1.CoderControlPlane{ObjectMeta: metav1.ObjectMeta{Name: other, Namespace: env.namespace}})) - requireReason("Applied") + rearm("two") require.NoError(t, k8sClient.Delete(ctx, key)) requireReason(credentialsecret.ReasonSecretNotFound) diff --git a/internal/controller/codermcpserver_disable.go b/internal/controller/codermcpserver_disable.go new file mode 100644 index 00000000..5da84c7f --- /dev/null +++ b/internal/controller/codermcpserver_disable.go @@ -0,0 +1,175 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" + "github.com/coder/coder-k8s/internal/credentialsecret" +) + +// disableMCPServerWithoutConsent handles a config that this object created +// when a credential read fails (#311, plan G8). Coder keeps the stored +// credentials and sends them to the URLs that Coder holds. Unless every +// credential that Coder holds comes from a recorded Secret that still passes +// the contract for those URLs, the controller disables the config in Coder +// with one PATCH that holds no credential and no destination field. +// +// An observed auth type user_oidc is disabled too: the controller cannot set +// it back without the credentials (plan 4.7 item 4). +// +// It fails closed on every stalled credential failure, as CoderAIProvider +// does (#290): a deleted Secret, a removed opt-in label or origin, and also +// a missing or malformed value. The Secret then no longer proves consent for +// the credential that Coder uses. +// +// The disable is a kill switch like a dashboard disable: it sets +// appliedGeneration, so a later sync reports DisabledExternally and only a +// spec edit after the disable enables the config again. +func (r *CoderMCPServerReconciler) disableMCPServerWithoutConsent( + ctx context.Context, sdk *codersdk.Client, server *coderv1alpha1.CoderMCPServer, orgID uuid.UUID, failed *mcpServerStep, +) (*mcpServerStep, error) { + id, err := uuid.Parse(server.Status.ConfigID) + if err != nil || id == uuid.Nil || failed == nil || !failed.stalled { + return nil, fmt.Errorf("assertion failed: MCP server %s/%s needs a config ID and a stalled credential failure to be disabled", server.Namespace, server.Name) + } + // Status messages are cut at 256 characters, so the new fact goes first. + stalled := func(format string, args ...any) *mcpServerStep { + return mcpServerStalled(mcpServerResync, failed.reason, "%s %s", fmt.Sprintf(format, args...), failed.message) + } + unchecked := func(format string, args ...any) *mcpServerStep { + return mcpServerWait(mcpServerRetry, failed.reason, "Coder can still send the credentials of MCP server config %s (%s). %s", id, fmt.Sprintf(format, args...), failed.message) + } + found, err := sdk.MCPServerConfigByID(ctx, orgID, id) + switch { + case isCoderNotFound(err): + return stalled("MCP server config %s does not exist in Coder.", id), nil + case err != nil: + return unchecked("get MCP server config: %s", coderErrorSummary(err)), nil + } + if found.ID != id || found.OrganizationID != orgID || found.Slug != server.Spec.Slug { + return mcpServerStalled(mcpServerResync, "CoderAnswerMismatch", + "assertion failed: Coder answered MCP server config %s with slug %s in organization %s when asked for %s", found.ID, found.Slug, found.OrganizationID, id), nil + } + if !found.Enabled { + return stalled("MCP server config %s is disabled in Coder.", id), nil + } + // user_oidc sends each chat user's login token to url (plan F14). Without + // a readable credential the controller cannot set the auth type back, so + // it disables the config. + ok := false + if found.AuthType != "user_oidc" { + ok, err = r.mcpServerConsentHolds(ctx, server, found) + if errors.Is(err, errMCPServerSecretChanged) { + return unchecked("a Secret changed during the consent check, so the controller reads it again"), nil + } else if err != nil { + return nil, err + } + } + if ok { + return stalled("Coder sends each credential of MCP server config %s only to origins that its recorded Secret allows.", id), nil + } + + disabled := false + updated, err := sdk.UpdateMCPServerConfig(ctx, orgID, id, codersdk.UpdateMCPServerConfigRequest{Enabled: &disabled}) + if err != nil { + return unchecked("the disable failed: %s", coderErrorSummary(err)), nil + } + if updated.ID != id || updated.OrganizationID != orgID || updated.Slug != server.Spec.Slug || updated.Enabled { + return mcpServerWait(mcpServerRetry, "CoderAnswerMismatch", + "Coder answered the disable of MCP server config %s with another or an enabled config. The controller disables it again.", id), nil + } + // The disable counts as an external disable of this generation, also of + // one that is not applied yet: the kill switch then holds until the next + // spec edit. + server.Status.AppliedGeneration = server.Generation + // The reason stays the credential reason, so it does not flap between + // this reconcile and the next one, which finds the config disabled. + return stalled("The controller disabled MCP server config %s in Coder: a credential Secret no longer proves consent for a credential that Coder uses. After the Secret is fixed, a spec edit enables the config.", id), nil +} + +// errMCPServerSecretChanged reports that a Secret changed between two reads +// of one consent check. +var errMCPServerSecretChanged = errors.New("a Secret changed during the consent check") + +// mcpServerConsentHolds reports whether every credential that found holds +// comes from a Secret in status.credentials that still passes the contract, +// with the same UID and a value that Coder can store, for each URL of found +// that receives it (plan 5.2). Coder holds the value of that Secret, so the +// spec's current URLs do not matter here. +// +// The spec must still name the recorded Secrets: a PATCH whose answer was +// lost after the commit can have sent a Secret of the spec that status does +// not record yet. CoderAIProvider reads the spec's ref for the same reason. +func (r *CoderMCPServerReconciler) mcpServerConsentHolds(ctx context.Context, server *coderv1alpha1.CoderMCPServer, found codersdk.MCPServerConfig) (bool, error) { + recorded := map[string]coderv1alpha1.CoderMCPServerCredentialStatus{} + for _, c := range server.Status.Credentials { + recorded[c.Field] = c + } + for _, c := range mcpServerCredentialRefs(server.Spec.Auth) { + if got, ok := recorded[c.field]; !ok || got.SecretName != c.ref.Name || got.SecretKey != c.ref.Key { + return false, nil + } + } + toURL := []string{found.URL} + toOAuth2 := []string{found.URL, found.OAuth2AuthURL, found.OAuth2TokenURL} + if found.OAuth2RevocationURL != "" { + toOAuth2 = append(toOAuth2, found.OAuth2RevocationURL) + } + held := []struct { + held bool + match func(field string) bool + destinations []string + headerValue bool + }{ + {found.HasAPIKey, func(f string) bool { return f == "apiKey" }, toURL, true}, + {found.HasCustomHeaders, func(f string) bool { return strings.HasPrefix(f, "header:") }, toURL, true}, + {found.HasOAuth2Secret, func(f string) bool { return f == "oauth2ClientSecret" }, toOAuth2, false}, + } + seen := map[string]credentialsecret.ChangeToken{} + for _, h := range held { + if !h.held { + continue + } + matched := 0 + for _, c := range server.Status.Credentials { + if !h.match(c.Field) { + continue + } + matched++ + for _, raw := range h.destinations { + destination, err := aiProviderBaseURL(raw) + if err != nil { + return false, nil //nolint:nilerr // An origin that does not parse has no proof of consent. + } + value, err := credentialsecret.Read(ctx, r.Client, server.Namespace, coderv1alpha1.SecretKeySelector{Name: c.SecretName, Key: c.SecretKey}, destination) + if credErr := (*credentialsecret.Error)(nil); errors.As(err, &credErr) { + return false, nil + } else if err != nil { + return false, err + } + // A malformed value also withdraws consent, as in the read of + // readMCPServerCredentials. + if string(value.Token.SecretUID) != c.SecretUID || mcpServerValueProblem(value, h.headerValue) != "" { + return false, nil + } + // All reads of one Secret must see one Secret state, as in + // readMCPServerCredentials. + if first, ok := seen[c.SecretName]; ok && first.ResourceVersion != value.Token.ResourceVersion { + return false, errMCPServerSecretChanged + } + seen[c.SecretName] = value.Token + } + } + if matched == 0 { + // A credential without a recorded Secret has no proof of consent. + return false, nil + } + } + return true, nil +} diff --git a/internal/controller/codermcpserver_disable_test.go b/internal/controller/codermcpserver_disable_test.go new file mode 100644 index 00000000..3644dba5 --- /dev/null +++ b/internal/controller/codermcpserver_disable_test.go @@ -0,0 +1,274 @@ +package controller_test + +import ( + "context" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" + "github.com/coder/coder-k8s/internal/credentialsecret" +) + +// Plan G8 and S12 (#311): when a credential Secret withdraws consent, the +// controller disables the config in Coder with one PATCH that holds no +// credential and no destination field. The disable is a kill switch: the +// restored consent does not undo it, and a spec edit enables the config. +func TestCoderMCPServerDisablesWithoutConsent(t *testing.T) { + t.Parallel() + oauth2 := func(s *coderv1alpha1.CoderMCPServerSpec) { useOAuth2(s, nil) } + for _, tc := range []struct { + name string + spec func(*coderv1alpha1.CoderMCPServerSpec) + secret func(context.Context, *testing.T, mcpServerEnv) *corev1.Secret + edit func(*corev1.Secret) // nil deletes the Secret. + reason string + }{ + {name: "Secret deleted", reason: credentialsecret.ReasonSecretNotFound}, + {name: "label removed", reason: credentialsecret.ReasonNotOptedIn, edit: func(s *corev1.Secret) { delete(s.Labels, credentialsecret.LabelKey) }}, + {name: "origin removed", reason: credentialsecret.ReasonDestinationNotAllowed, edit: func(s *corev1.Secret) { + s.Annotations[credentialsecret.DestinationsAnnotation] = "https://other.example.com" + }}, + {name: "value malformed", reason: credentialsecret.ReasonMalformed, edit: func(s *corev1.Secret) { s.Data["token"] = []byte(testMCPKey + "\nx") }}, + { + name: "OAuth2 token origin removed", reason: credentialsecret.ReasonDestinationNotAllowed, spec: oauth2, + secret: func(ctx context.Context, t *testing.T, env mcpServerEnv) *corev1.Secret { + return env.secret(ctx, t, "tracker-oauth", map[string]string{"client-secret": testMCPClientSecret}, testMCPOrigin, testMCPAuthOrigin) + }, + edit: func(s *corev1.Secret) { s.Annotations[credentialsecret.DestinationsAnnotation] = testMCPOrigin }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + env := newMCPServerEnv(ctx, t, true) + if tc.secret == nil { + tc.secret = func(ctx context.Context, t *testing.T, env mcpServerEnv) *corev1.Secret { + return env.keySecret(ctx, t, testMCPKey) + } + } + secret := tc.secret(ctx, t, env) + consented := secret.DeepCopy() + got, _ := env.applied(ctx, t, tc.spec) + before := env.stored(t, got) + if tc.edit == nil { + require.NoError(t, k8sClient.Delete(ctx, secret)) + } else { + editSecret(ctx, t, secret, tc.edit) + } + + // A failed disable is retried, and the message says that Coder + // can still send the credentials. + env.fake.failNext(routeUpdateMCPServer, fakeFault{Status: http.StatusBadGateway}) + got, result := env.reconcile(ctx, t, got) + require.Contains(t, requireMCPServerStep(t, got, false, tc.reason), "Coder can still send the credentials") + require.Equal(t, 30*time.Second, result.RequeueAfter) + + mark := len(env.writeRoutes()) + got, result = env.reconcile(ctx, t, got) + require.Contains(t, requireMCPServerStep(t, got, true, tc.reason), "The controller disabled MCP server config") + require.Equal(t, 10*time.Minute, result.RequeueAfter) + writes := env.writesSince(mark) + require.Len(t, writes, 1) + require.Equal(t, routeUpdateMCPServer, writes[0].route) + require.JSONEq(t, `{"enabled":false}`, string(writes[0].body)) + before.view.Enabled = false + require.Equal(t, before, env.stored(t, got), "only enabled changed") + + mark = len(env.writeRoutes()) + got, _ = env.reconcile(ctx, t, got) + require.Contains(t, requireMCPServerStep(t, got, true, tc.reason), "is disabled in Coder") + require.Empty(t, env.writesSince(mark)) + + // Restored consent does not enable the config again. + if tc.edit == nil { + consented.ResourceVersion, consented.UID = "", "" + createSecret(ctx, t, consented) + } else { + editSecret(ctx, t, secret, func(s *corev1.Secret) { + s.Labels, s.Annotations, s.Data = consented.Labels, consented.Annotations, consented.Data + }) + } + got, _ = env.reconcile(ctx, t, got) + requireMCPServerStep(t, got, true, "DisabledExternally") + require.Empty(t, env.writesSince(mark)) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(got), got)) + got.Spec.Description = "re-armed" + require.NoError(t, k8sClient.Update(ctx, got)) + got, _ = env.reconcile(ctx, t, got) + requireMCPServerApplied(t, got) + require.True(t, env.stored(t, got).view.Enabled) + }) + } +} + +// Plan S12: a spec change to an origin that the Secret does not list sends +// nothing. The consent check uses what Coder holds: Coder keeps the old URL, +// which the recorded Secret still allows, so the config stays enabled. A +// credential that Coder holds without a recorded Secret, or a recorded +// Secret replaced by another one with the same name, has no proof of consent, +// so the config is disabled. So is a config whose auth type was switched to +// user_oidc, which the controller cannot set back without the credential. +func TestCoderMCPServerConsentCheckUsesCoderState(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + prepare func(context.Context, *testing.T, mcpServerEnv, *corev1.Secret, uuid.UUID) + edit func(*coderv1alpha1.CoderMCPServerSpec) // nil moves the URL to an unlisted origin. + reason string // "" is CredentialDestinationNotAllowed. + disabled bool + }{ + {name: "Coder keeps an allowed URL"}, + { + // A PATCH whose answer was lost can have sent the new Secret. + name: "spec names another Secret", disabled: true, reason: credentialsecret.ReasonSecretNotFound, + edit: func(s *coderv1alpha1.CoderMCPServerSpec) { s.Auth.APIKey.SecretRef.Name = "other-mcp" }, + }, + {name: "Coder holds an unrecorded credential", disabled: true, prepare: func(ctx context.Context, t *testing.T, env mcpServerEnv, _ *corev1.Secret, id uuid.UUID) { + _, err := env.dashboard(t).UpdateMCPServerConfig(ctx, env.orgID, id, codersdk.UpdateMCPServerConfigRequest{CustomHeaders: &map[string]string{"X-Extra": "v"}}) + require.NoError(t, err) + }}, + {name: "Coder uses user_oidc", disabled: true, prepare: func(ctx context.Context, t *testing.T, env mcpServerEnv, _ *corev1.Secret, id uuid.UUID) { + _, err := env.dashboard(t).UpdateMCPServerConfig(ctx, env.orgID, id, codersdk.UpdateMCPServerConfigRequest{AuthType: ptrTo("user_oidc")}) + require.NoError(t, err) + }}, + {name: "Secret replaced", disabled: true, prepare: func(ctx context.Context, t *testing.T, env mcpServerEnv, secret *corev1.Secret, _ uuid.UUID) { + require.NoError(t, k8sClient.Delete(ctx, secret)) + env.keySecret(ctx, t, testMCPKey) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + env := newMCPServerEnv(ctx, t, true) + secret := env.keySecret(ctx, t, testMCPKey) + got, id := env.applied(ctx, t, nil) + if tc.prepare != nil { + tc.prepare(ctx, t, env, secret, id) + } + before := env.stored(t, got) + + if tc.edit == nil { + tc.edit = func(s *coderv1alpha1.CoderMCPServerSpec) { s.URL = "https://other.example.com/mcp" } + } + if tc.reason == "" { + tc.reason = credentialsecret.ReasonDestinationNotAllowed + } + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(got), got)) + tc.edit(&got.Spec) + require.NoError(t, k8sClient.Update(ctx, got)) + mark := len(env.writeRoutes()) + got, _ = env.reconcile(ctx, t, got) + message := requireMCPServerStep(t, got, true, tc.reason) + if tc.disabled { + require.Contains(t, message, "The controller disabled MCP server config") + require.Len(t, env.writesSince(mark), 1) + before.view.Enabled = false + } else { + require.Contains(t, message, "only to origins that its recorded Secret allows") + require.Empty(t, env.writesSince(mark)) + } + require.Equal(t, before, env.stored(t, got)) + }) + } +} + +// A disable while a newer generation is not applied yet also holds that +// generation: restored consent does not apply it, and only a spec edit after +// the disable enables the config. +func TestCoderMCPServerDisableHoldsPendingGeneration(t *testing.T) { + t.Parallel() + ctx := context.Background() + env := newMCPServerEnv(ctx, t, true) + secret := env.keySecret(ctx, t, testMCPKey) + consented := secret.DeepCopy() + got, _ := env.applied(ctx, t, nil) + edit := func(description string) { + t.Helper() + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(got), got)) + got.Spec.Description = description + require.NoError(t, k8sClient.Update(ctx, got)) + } + edit("pending") + env.fake.failNext(routeUpdateMCPServer, fakeFault{Status: http.StatusBadGateway}) + got, _ = env.reconcile(ctx, t, got) + requireMCPServerStep(t, got, false, "CoderUnavailable") + + require.NoError(t, k8sClient.Delete(ctx, secret)) + got, _ = env.reconcile(ctx, t, got) + requireMCPServerStep(t, got, true, credentialsecret.ReasonSecretNotFound) + require.False(t, env.stored(t, got).view.Enabled) + + consented.ResourceVersion, consented.UID = "", "" + createSecret(ctx, t, consented) + mark := len(env.writeRoutes()) + got, _ = env.reconcile(ctx, t, got) + requireMCPServerStep(t, got, true, "DisabledExternally") + require.Empty(t, env.writesSince(mark)) + require.Empty(t, env.stored(t, got).view.Description, "the pending generation is not applied") + + edit("after the disable") + got, _ = env.reconcile(ctx, t, got) + requireMCPServerApplied(t, got) + require.True(t, env.stored(t, got).view.Enabled) +} + +// The disable answer must be for the same config, as the GET before it. +func TestCoderMCPServerDisableChecksTheAnswer(t *testing.T) { + t.Parallel() + ctx := context.Background() + env := newMCPServerEnv(ctx, t, true) + secret := env.keySecret(ctx, t, testMCPKey) + got, _ := env.applied(ctx, t, nil) + require.NoError(t, k8sClient.Delete(ctx, secret)) + env.fake.failNext(routeUpdateMCPServer, rewriteUpdate(func(c *codersdk.MCPServerConfig) { c.Slug = "other" })) + got, result := env.reconcile(ctx, t, got) + requireMCPServerStep(t, got, false, "CoderAnswerMismatch") + require.Equal(t, 30*time.Second, result.RequeueAfter) +} + +// All reads of one Secret in the consent check must see one Secret state, as +// in the credential read. A change between them gives a retry and no write. +func TestCoderMCPServerConsentCheckNeedsOneSecretState(t *testing.T) { + t.Parallel() + ctx := context.Background() + env := newMCPServerEnv(ctx, t, true) + oauth2MCPSecret(ctx, t, env) + got, _ := env.applied(ctx, t, func(s *coderv1alpha1.CoderMCPServerSpec) { useOAuth2(s, nil) }) + require.NoError(t, k8sClient.Get(ctx, client.ObjectKeyFromObject(got), got)) + got.Spec.URL = "https://other.example.com/mcp" + require.NoError(t, k8sClient.Update(ctx, got)) + + // Read 1 is the failed credential read for the new URL. Reads 2 and 3 are + // the consent check for Coder's URL and authorize URL. Read 3 sees another + // resourceVersion, so the check stops there. + base, err := client.NewWithWatch(cfg, client.Options{Scheme: scheme}) + require.NoError(t, err) + var gets atomic.Int32 + env.r.Client = interceptor.NewClient(base, interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if err := c.Get(ctx, key, obj, opts...); err != nil { + return err + } + if s, ok := obj.(*corev1.Secret); ok && key.Name == "tracker-oauth" && gets.Add(1) == 3 { + s.ResourceVersion += "0" + } + return nil + }, + }) + mark := len(env.writeRoutes()) + got, result := env.reconcile(ctx, t, got) + require.Contains(t, requireMCPServerStep(t, got, false, credentialsecret.ReasonDestinationNotAllowed), "a Secret changed during the consent check") + require.Equal(t, 30*time.Second, result.RequeueAfter) + require.Equal(t, int32(3), gets.Load()) + require.Empty(t, env.writesSince(mark)) +}