Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion api/v1alpha1/codermcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ type CoderMCPServerStatus struct {
// ConfigID is the Coder MCP server config ID.
// +optional
ConfigID string `json:"configID,omitempty"`
// Enabled is the enabled flag of the Coder MCP server config at the
// controller's last read or update of it. It is empty while Coder holds no
// config of this object, or before the first read.
// +optional
Enabled *bool `json:"enabled,omitempty"`
// CreateAttemptTime is set before every create request. While it is set
// and ConfigID is empty, a create request can be in flight or already
// committed in Coder.
Expand All @@ -332,7 +337,7 @@ type CoderMCPServerStatus struct {
// +kubebuilder:printcolumn:name="Organization",type=string,JSONPath=`.spec.organization`
// +kubebuilder:printcolumn:name="Slug",type=string,JSONPath=`.spec.slug`
// +kubebuilder:printcolumn:name="Auth",type=string,JSONPath=`.spec.auth.type`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.spec.enabled`
// +kubebuilder:printcolumn:name="Enabled",type=boolean,JSONPath=`.status.enabled`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"

Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion config/crd/bases/coder.com_codermcpservers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ spec:
- jsonPath: .spec.auth.type
name: Auth
type: string
- jsonPath: .spec.enabled
- jsonPath: .status.enabled
name: Enabled
type: boolean
- jsonPath: .status.conditions[?(@.type=="Ready")].status
Expand Down Expand Up @@ -537,6 +537,12 @@ spec:
x-kubernetes-list-map-keys:
- field
x-kubernetes-list-type: map
enabled:
description: |-
Enabled is the enabled flag of the Coder MCP server config at the
controller's last read or update of it. It is empty while Coder holds no
config of this object, or before the first read.
type: boolean
observedGeneration:
description: ObservedGeneration is the generation the controller last
processed.
Expand Down
9 changes: 8 additions & 1 deletion config/gitops/argocd-health-codermcpserver.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ local st = obj.status
if st == nil or st.observedGeneration == nil or st.observedGeneration ~= obj.metadata.generation then
return hs
end
-- A condition from an older generation does not describe the current spec.
local conditions = {}
for _, c in ipairs(st.conditions or {}) do
if c.observedGeneration == obj.metadata.generation then
table.insert(conditions, c)
end
end
for _, c in ipairs(conditions) do
if c.type == "Ready" and c.reason ~= nil and c.message ~= nil then
hs.message = c.reason .. ": " .. c.message
end
end
for _, c in ipairs(st.conditions or {}) do
for _, c in ipairs(conditions) do
if c.type == "Ready" and c.status == "True" then
hs.status = "Healthy"
elseif c.type == "Stalled" and c.status == "True" then
Expand Down
2 changes: 1 addition & 1 deletion config/gitops/flux-healthcheck-codermcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
# docs/how-to/manage-mcp-servers.md copies this file. A test keeps the copy equal.
- apiVersion: coder.com/v1alpha1
kind: CoderMCPServer
inProgress: "!has(status.observedGeneration) || status.observedGeneration != metadata.generation || !has(status.conditions) || status.conditions.exists(c, c.type == 'Reconciling' && c.status == 'True')"
inProgress: "!has(status.observedGeneration) || status.observedGeneration != metadata.generation || !has(status.conditions) || !status.conditions.exists(c, c.type == 'Ready' && has(c.observedGeneration) && c.observedGeneration == metadata.generation) || status.conditions.exists(c, c.type == 'Reconciling' && c.status == 'True')"
failed: "status.conditions.exists(c, c.type == 'Stalled' && c.status == 'True')"
current: "status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
8 changes: 8 additions & 0 deletions config/gitops/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ func mcpServerCases() []healthCase {
stale := readyReconcilingStalled("ready", "Applied")
stale["observedGeneration"] = int64(1)
noConditions := map[string]any{"observedGeneration": int64(2)}
// An update interrupted before Coder committed it, as stored by
// controllers before the #415 fix: only the top-level generation moved.
interrupted := readyReconcilingStalled("ready", "Applied")
interrupted["appliedGeneration"] = int64(1)
for _, c := range interrupted["conditions"].([]any) {
c.(map[string]any)["observedGeneration"] = int64(1)
}
deleting := mcpServerObject(readyReconcilingStalled("ready", "Applied"))
deleting["metadata"].(map[string]any)["deletionTimestamp"] = "2026-10-14T10:00:00Z"
deletingWait := mcpServerObject(readyReconcilingStalled("reconciling", "CoderUnavailable"))
Expand All @@ -165,6 +172,7 @@ func mcpServerCases() []healthCase {
{"stale observedGeneration", mcpServerObject(stale), "Progressing", "Waiting for the controller", "InProgress", ""},
{"no conditions", mcpServerObject(noConditions), "Progressing", "Waiting for the controller", "InProgress", ""},
{"applied", mcpServerObject(readyReconcilingStalled("ready", "Applied")), "Healthy", "Applied: details", "Current", ""},
{"stale conditions", mcpServerObject(interrupted), "Progressing", "Waiting for the controller", "InProgress", ""},
{"control plane not ready", mcpServerObject(readyReconcilingStalled("reconciling", "ControlPlaneNotReady")), "Progressing", "ControlPlaneNotReady: details", "InProgress", ""},
{"Coder unavailable", mcpServerObject(readyReconcilingStalled("reconciling", "CoderUnavailable")), "Progressing", "CoderUnavailable: details", "InProgress", ""},
{"group not found", mcpServerObject(readyReconcilingStalled("stalled", "GroupNotFound")), "Degraded", "GroupNotFound: details", "Failed", ""},
Expand Down
8 changes: 7 additions & 1 deletion dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5229,7 +5229,7 @@ spec:
- jsonPath: .spec.auth.type
name: Auth
type: string
- jsonPath: .spec.enabled
- jsonPath: .status.enabled
name: Enabled
type: boolean
- jsonPath: .status.conditions[?(@.type=="Ready")].status
Expand Down Expand Up @@ -5742,6 +5742,12 @@ spec:
x-kubernetes-list-map-keys:
- field
x-kubernetes-list-type: map
enabled:
description: |-
Enabled is the enabled flag of the Coder MCP server config at the
controller's last read or update of it. It is empty while Coder holds no
config of this object, or before the first read.
type: boolean
observedGeneration:
description: ObservedGeneration is the generation the controller last
processed.
Expand Down
17 changes: 13 additions & 4 deletions docs/how-to/manage-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ kubectl -n coder get codermcpservers
kubectl -n coder get codermcpserver example -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{"\n"}{end}'
```

The `ENABLED` column shows `status.enabled`: the enabled flag of the config in Coder at the operator's last read or update. It is empty while Coder holds no config of this object, for example when the credential Secret is refused before the create, and before the first read.

The object has the conditions `Ready`, `Reconciling` and `Stalled`. A stalled object waits for a change of its spec or of an input.

| Reason | Meaning |
Expand Down Expand Up @@ -228,11 +230,11 @@ These limits come from Coder, and the operator cannot change them:

## GitOps health checks

Argo CD and Flux need health rules to wait for a `CoderMCPServer`. The rules live in `config/gitops/`. This page copies them, and a unit test keeps the copies equal to the files. Both rules wait until `status.observedGeneration` equals `metadata.generation`. Then they read the conditions:
Argo CD and Flux need health rules to wait for a `CoderMCPServer`. The rules live in `config/gitops/`. This page copies them, and a unit test keeps the copies equal to the files. The controller sets `status.observedGeneration` to `metadata.generation` only when it writes the conditions for that generation. Both rules wait until `status.observedGeneration` equals `metadata.generation`. They also ignore a condition whose own `observedGeneration` is older, so a status that an older controller wrote in the middle of an update does not count as healthy. Then they read the conditions:

| Conditions | Argo CD | Flux |
| --- | --- | --- |
| no status, or a stale `observedGeneration` | `Progressing` | `InProgress` (`Unknown` before the first status) |
| no status, or a stale `observedGeneration` on the status or the `Ready` condition | `Progressing` | `InProgress` (`Unknown` before the first status) |
| `Reconciling=True` | `Progressing` | `InProgress` |
| `Ready=True` | `Healthy` | `Current` |
| `Stalled=True` | `Degraded` | `Failed` |
Expand All @@ -257,12 +259,19 @@ local st = obj.status
if st == nil or st.observedGeneration == nil or st.observedGeneration ~= obj.metadata.generation then
return hs
end
-- A condition from an older generation does not describe the current spec.
local conditions = {}
for _, c in ipairs(st.conditions or {}) do
if c.observedGeneration == obj.metadata.generation then
table.insert(conditions, c)
end
end
for _, c in ipairs(conditions) do
if c.type == "Ready" and c.reason ~= nil and c.message ~= nil then
hs.message = c.reason .. ": " .. c.message
end
end
for _, c in ipairs(st.conditions or {}) do
for _, c in ipairs(conditions) do
if c.type == "Ready" and c.status == "True" then
hs.status = "Healthy"
elseif c.type == "Stalled" and c.status == "True" then
Expand All @@ -281,7 +290,7 @@ Add this entry (`config/gitops/flux-healthcheck-codermcpserver.yaml`) to `spec.h
# docs/how-to/manage-mcp-servers.md copies this file. A test keeps the copy equal.
- apiVersion: coder.com/v1alpha1
kind: CoderMCPServer
inProgress: "!has(status.observedGeneration) || status.observedGeneration != metadata.generation || !has(status.conditions) || status.conditions.exists(c, c.type == 'Reconciling' && c.status == 'True')"
inProgress: "!has(status.observedGeneration) || status.observedGeneration != metadata.generation || !has(status.conditions) || !status.conditions.exists(c, c.type == 'Ready' && has(c.observedGeneration) && c.observedGeneration == metadata.generation) || status.conditions.exists(c, c.type == 'Reconciling' && c.status == 'True')"
failed: "status.conditions.exists(c, c.type == 'Stalled' && c.status == 'True')"
current: "status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
```
Expand Down
1 change: 1 addition & 0 deletions docs/reference/api/codermcpserver.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
| `appliedGeneration` | integer | AppliedGeneration is the last generation whose spec Coder fully holds, or that a kill switch holds: when the controller disables the config because a credential Secret or the control plane withdrew consent, it sets this field before it sends the disable, so only a later spec edit enables the config again. Ready tells whether Coder holds the spec. |
| `organizationID` | string | OrganizationID is the Coder organization ID that spec.organization resolved to first. |
| `configID` | string | ConfigID is the Coder MCP server config ID. |
| `enabled` | boolean | Enabled is the enabled flag of the Coder MCP server config at the controller's last read or update of it. It is empty while Coder holds no config of this object, or before the first read. |
| `createAttemptTime` | [Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta) | CreateAttemptTime is set before every create request. While it is set and ConfigID is empty, a create request can be in flight or already committed in Coder. |
| `credentials` | [CoderMCPServerCredentialStatus](#codermcpservercredentialstatus) array | Credentials record the Secret values last sent to Coder. |
| `conditions` | [Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array | Conditions are Ready, Reconciling, and Stalled. |
Expand Down
18 changes: 9 additions & 9 deletions internal/controller/coderaiprovider_column_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"

coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1"
"github.com/coder/coder-k8s/internal/credentialsecret"
)

// enabledColumn returns the Enabled cell that kubectl get prints for
// provider, read as a server-side table from the API server.
func enabledColumn(ctx context.Context, t *testing.T, provider *coderv1alpha1.CoderAIProvider) any {
// enabledColumn returns the Enabled cell that kubectl get prints for obj of
// the coder.com resource, read as a server-side table from the API server.
func enabledColumn(ctx context.Context, t *testing.T, resource string, obj client.Object) any {
t.Helper()
server, _, err := rest.DefaultServerUrlFor(cfg)
require.NoError(t, err)
httpClient, err := rest.HTTPClientFor(cfg)
require.NoError(t, err)
server.Path = "/apis/coder.com/v1alpha1/namespaces/" + provider.Namespace + "/coderaiproviders/" + provider.Name
server.Path = "/apis/coder.com/v1alpha1/namespaces/" + obj.GetNamespace() + "/" + resource + "/" + obj.GetName()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.String(), nil)
require.NoError(t, err)
req.Header.Set("Accept", "application/json;as=Table;v=v1;g=meta.k8s.io")
Expand Down Expand Up @@ -55,23 +55,23 @@ func TestCoderAIProviderEnabledColumn(t *testing.T) {
got, _ := env.reconcile(ctx, t, env.createProvider(ctx, t, nil))
requireAIProviderStep(t, got, true, credentialsecret.ReasonSecretNotFound)
require.True(t, *got.Spec.Enabled)
require.Nil(t, enabledColumn(ctx, t, got))
require.Nil(t, enabledColumn(ctx, t, "coderaiproviders", got))
})
t.Run("kill switch, then deleted in Coder", func(t *testing.T) {
t.Parallel()
env, secret, provider := newReadyAIProvider(ctx, t)
require.Equal(t, true, enabledColumn(ctx, t, provider))
require.Equal(t, true, enabledColumn(ctx, t, "coderaiproviders", provider))

consented := secret.DeepCopy()
editSecret(ctx, t, secret, func(s *corev1.Secret) { delete(s.Labels, credentialsecret.LabelKey) })
got, _ := env.reconcile(ctx, t, provider)
requireAIProviderStep(t, got, true, credentialsecret.ReasonNotOptedIn)
require.Equal(t, false, enabledColumn(ctx, t, got))
require.Equal(t, false, enabledColumn(ctx, t, "coderaiproviders", got))

editSecret(ctx, t, secret, func(s *corev1.Secret) { s.Labels = consented.Labels })
require.NoError(t, env.fake.client(t, 2*time.Second).DeleteAIProvider(ctx, got.Status.ProviderID))
got, _ = env.reconcile(ctx, t, got)
requireAIProviderStep(t, got, true, "DeletedExternally")
require.Nil(t, enabledColumn(ctx, t, got))
require.Nil(t, enabledColumn(ctx, t, "coderaiproviders", got))
})
}
6 changes: 5 additions & 1 deletion internal/controller/codermcpserver_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ func (r *CoderMCPServerReconciler) syncMCPServer(
// Only Coder's own 404 proves that the config is gone. A proxy page or
// "Route not found." waits below and changes nothing (#337).
case isCoderResourceNotFound(err) && applied:
server.Status.Enabled = nil
return mcpServerStalled(mcpServerResync, "DeletedExternally",
"MCP server config %s was deleted in Coder. The controller does not create it again until the spec changes.", id), false, nil
case isCoderResourceNotFound(err):
server.Status.ConfigID, server.Status.Credentials = "", nil
server.Status.ConfigID, server.Status.Credentials, server.Status.Enabled = "", nil, nil
r.sentCredentials.Delete(client.ObjectKeyFromObject(server))
return nil, true, nil
case err != nil:
Expand All @@ -64,6 +65,7 @@ func (r *CoderMCPServerReconciler) syncMCPServer(
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), false, nil
}
server.Status.Enabled = &found.Enabled

// user_oidc sends each chat user's login token to url (plan F14), so it is
// set back even when a kill switch holds every other write.
Expand Down Expand Up @@ -127,6 +129,7 @@ func (r *CoderMCPServerReconciler) syncMCPServer(
if field := mcpServerMismatch(server.Spec.Auth, check, updated); field != "" || updated.ID != id {
return mcpServerStalled(mcpServerResync, "CoderAnswerMismatch", "assertion failed: Coder answered the update of MCP server config %s with another %s", id, cmp.Or(field, "ID")), false, nil
}
server.Status.Enabled = &updated.Enabled
r.rememberSentCredentials(server, sent, values)
} else if field := mcpServerMismatch(server.Spec.Auth, full, found); field != "" {
return nil, false, fmt.Errorf("assertion failed: MCP server %s/%s differs in %s, but the sync request is empty", server.Namespace, server.Name, field)
Expand Down Expand Up @@ -160,6 +163,7 @@ func (r *CoderMCPServerReconciler) disableForMissingGroup(
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)
}
server.Status.Enabled = &updated.Enabled
return missing
}

Expand Down
Loading
Loading