diff --git a/config/gitops/argocd-health-codermcpserver.lua b/config/gitops/argocd-health-codermcpserver.lua new file mode 100644 index 000000000..7e0ac9e87 --- /dev/null +++ b/config/gitops/argocd-health-codermcpserver.lua @@ -0,0 +1,28 @@ +-- Argo CD health check for CoderMCPServer (coder.com/v1alpha1). +-- Install it as the argocd-cm key +-- resource.customizations.health.coder.com_CoderMCPServer. +-- docs/how-to/manage-mcp-servers.md copies this file. A test keeps the copy equal. +local hs = { status = "Progressing", message = "Waiting for the controller" } +if obj.metadata.deletionTimestamp ~= nil then + -- Argo CD shows deletionMessage, not message, for a resource being deleted. + hs.deletionMessage = "Deleting the Coder MCP server config" + hs.message = hs.deletionMessage + return hs +end +local st = obj.status +if st == nil or st.observedGeneration == nil or st.observedGeneration ~= obj.metadata.generation then + return hs +end +for _, c in ipairs(st.conditions or {}) 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 + if c.type == "Ready" and c.status == "True" then + hs.status = "Healthy" + elseif c.type == "Stalled" and c.status == "True" then + hs.status = "Degraded" + end +end +return hs diff --git a/config/gitops/flux-healthcheck-codermcpserver.yaml b/config/gitops/flux-healthcheck-codermcpserver.yaml new file mode 100644 index 000000000..ac7696642 --- /dev/null +++ b/config/gitops/flux-healthcheck-codermcpserver.yaml @@ -0,0 +1,9 @@ +# Flux health check expressions for CoderMCPServer (coder.com/v1alpha1). +# Add this entry to spec.healthCheckExprs of a Flux v2.5+ Kustomization +# with spec.wait: true. +# 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')" + failed: "status.conditions.exists(c, c.type == 'Stalled' && c.status == 'True')" + current: "status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')" diff --git a/config/gitops/health_test.go b/config/gitops/health_test.go index a2314c708..ca7cc4ba9 100644 --- a/config/gitops/health_test.go +++ b/config/gitops/health_test.go @@ -1,5 +1,6 @@ -// Package gitops_test checks the GitOps health rules for CoderTemplateTest. -// The rules are data files, so this package has no production code. +// Package gitops_test checks the GitOps health rules for CoderTemplateTest +// and CoderMCPServer. The rules are data files, so this package has no +// production code. package gitops_test import ( @@ -15,11 +16,20 @@ import ( "sigs.k8s.io/yaml" ) -const ( - luaPath = "argocd-health-codertemplatetest.lua" - fluxPath = "flux-healthcheck-codertemplatetest.yaml" - docPath = "../../docs/how-to/gitops.md" -) +// ruleSet is the Argo CD script and the Flux entry of one kind, the doc page +// that copies them, and the cases that both must pass. +type ruleSet struct { + kind string + luaPath string + fluxPath string + docPath string + cases func() []healthCase +} + +var ruleSets = []ruleSet{ + {"CoderTemplateTest", "argocd-health-codertemplatetest.lua", "flux-healthcheck-codertemplatetest.yaml", "../../docs/how-to/gitops.md", healthCases}, + {"CoderMCPServer", "argocd-health-codermcpserver.lua", "flux-healthcheck-codermcpserver.yaml", "../../docs/how-to/manage-mcp-servers.md", mcpServerCases}, +} // fluxHealthCheck is one entry of a Flux Kustomization spec.healthCheckExprs. type fluxHealthCheck struct { @@ -103,48 +113,118 @@ func healthCases() []healthCase { } } +// mcpServerObject returns a CoderMCPServer at generation 2 with the given +// status. +func mcpServerObject(status map[string]any) map[string]any { + obj := map[string]any{ + "apiVersion": "coder.com/v1alpha1", + "kind": "CoderMCPServer", + "metadata": map[string]any{"name": "github", "namespace": "coder", "generation": int64(2)}, + "spec": map[string]any{"organization": "coder", "slug": "github"}, + } + if status != nil { + obj["status"] = status + } + return obj +} + +// mcpServerStatus returns the three conditions that the controller always +// writes together, with one reason and message: ready, reconciling (neither +// ready nor stalled), or stalled. +func mcpServerStatus(state, reason string) map[string]any { + ready, reconciling, stalled := "False", "True", "False" + switch state { + case "ready": + ready, reconciling = "True", "False" + case "stalled": + reconciling, stalled = "False", "True" + case "reconciling": + default: + panic("assertion failed: unknown MCP server fixture state " + state) + } + condition := func(conditionType, status string) any { + return map[string]any{"type": conditionType, "status": status, "reason": reason, "message": "details", "observedGeneration": int64(2)} + } + return map[string]any{ + "observedGeneration": int64(2), + "conditions": []any{condition("Ready", ready), condition("Reconciling", reconciling), condition("Stalled", stalled)}, + } +} + +func mcpServerCases() []healthCase { + stale := mcpServerStatus("ready", "Applied") + stale["observedGeneration"] = int64(1) + noConditions := map[string]any{"observedGeneration": int64(2)} + deleting := mcpServerObject(mcpServerStatus("ready", "Applied")) + deleting["metadata"].(map[string]any)["deletionTimestamp"] = "2026-10-14T10:00:00Z" + deletingWait := mcpServerObject(mcpServerStatus("reconciling", "CoderUnavailable")) + deletingWait["metadata"].(map[string]any)["deletionTimestamp"] = "2026-10-14T10:00:00Z" + return []healthCase{ + {"no status", mcpServerObject(nil), "Progressing", "Waiting for the controller", "Unknown", ""}, + {"stale observedGeneration", mcpServerObject(stale), "Progressing", "Waiting for the controller", "InProgress", ""}, + {"no conditions", mcpServerObject(noConditions), "Progressing", "Waiting for the controller", "InProgress", ""}, + {"applied", mcpServerObject(mcpServerStatus("ready", "Applied")), "Healthy", "Applied: details", "Current", ""}, + {"control plane not ready", mcpServerObject(mcpServerStatus("reconciling", "ControlPlaneNotReady")), "Progressing", "ControlPlaneNotReady: details", "InProgress", ""}, + {"Coder unavailable", mcpServerObject(mcpServerStatus("reconciling", "CoderUnavailable")), "Progressing", "CoderUnavailable: details", "InProgress", ""}, + {"group not found", mcpServerObject(mcpServerStatus("stalled", "GroupNotFound")), "Degraded", "GroupNotFound: details", "Failed", ""}, + {"not allowed", mcpServerObject(mcpServerStatus("stalled", "MCPServersNotAllowed")), "Degraded", "MCPServersNotAllowed: details", "Failed", ""}, + {"disabled externally", mcpServerObject(mcpServerStatus("stalled", "DisabledExternally")), "Degraded", "DisabledExternally: details", "Failed", ""}, + {"credential not opted in", mcpServerObject(mcpServerStatus("stalled", "CredentialNotOptedIn")), "Degraded", "CredentialNotOptedIn: details", "Failed", ""}, + {"slug conflict", mcpServerObject(mcpServerStatus("stalled", "SlugConflict")), "Degraded", "SlugConflict: details", "Failed", ""}, + // Flux has no deletion rule: it waits for pruned objects separately. + {"being deleted", deleting, "Progressing", "Deleting the Coder MCP server config", "Current", "Deleting the Coder MCP server config"}, + {"being deleted while Coder is down", deletingWait, "Progressing", "Deleting the Coder MCP server config", "InProgress", "Deleting the Coder MCP server config"}, + } +} + func TestArgoCDHealth(t *testing.T) { t.Parallel() - script := readFile(t, luaPath) - for _, tc := range healthCases() { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - gotStatus, gotMessage, gotDeletion := argoHealth(t, script, tc.obj) - if gotStatus != tc.argoStatus || gotMessage != tc.argoMessage || gotDeletion != tc.argoDeletion { - t.Fatalf("got %q %q %q, want %q %q %q", gotStatus, gotMessage, gotDeletion, tc.argoStatus, tc.argoMessage, tc.argoDeletion) - } - }) + for _, rules := range ruleSets { + script := readFile(t, rules.luaPath) + for _, tc := range rules.cases() { + t.Run(rules.kind+"/"+tc.name, func(t *testing.T) { + t.Parallel() + gotStatus, gotMessage, gotDeletion := argoHealth(t, script, tc.obj) + if gotStatus != tc.argoStatus || gotMessage != tc.argoMessage || gotDeletion != tc.argoDeletion { + t.Fatalf("got %q %q %q, want %q %q %q", gotStatus, gotMessage, gotDeletion, tc.argoStatus, tc.argoMessage, tc.argoDeletion) + } + }) + } } } func TestFluxHealth(t *testing.T) { t.Parallel() - var checks []fluxHealthCheck - if err := yaml.UnmarshalStrict([]byte(readFile(t, fluxPath)), &checks); err != nil { - t.Fatalf("parse %s: %v", fluxPath, err) - } - if len(checks) != 1 || checks[0].APIVersion != "coder.com/v1alpha1" || checks[0].Kind != "CoderTemplateTest" { - t.Fatalf("want one CoderTemplateTest entry, got %+v", checks) - } - for _, tc := range healthCases() { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - if got := fluxHealth(t, checks[0], tc.obj); got != tc.fluxStatus { - t.Fatalf("got %q, want %q", got, tc.fluxStatus) - } - }) + for _, rules := range ruleSets { + var checks []fluxHealthCheck + if err := yaml.UnmarshalStrict([]byte(readFile(t, rules.fluxPath)), &checks); err != nil { + t.Fatalf("parse %s: %v", rules.fluxPath, err) + } + if len(checks) != 1 || checks[0].APIVersion != "coder.com/v1alpha1" || checks[0].Kind != rules.kind { + t.Fatalf("want one %s entry, got %+v", rules.kind, checks) + } + for _, tc := range rules.cases() { + t.Run(rules.kind+"/"+tc.name, func(t *testing.T) { + t.Parallel() + if got := fluxHealth(t, checks[0], tc.obj); got != tc.fluxStatus { + t.Fatalf("got %q, want %q", got, tc.fluxStatus) + } + }) + } } } -// TestDocCopiesRules keeps the copies in docs/how-to/gitops.md equal to the -// source files, because the docs build has no snippet include. +// TestDocCopiesRules keeps the copies in the docs equal to the source files, +// because the docs build has no snippet include. func TestDocCopiesRules(t *testing.T) { t.Parallel() - doc := readFile(t, docPath) - for lang, path := range map[string]string{"lua": luaPath, "yaml": fluxPath} { - block := "```" + lang + "\n" + readFile(t, path) + "```\n" - if !strings.Contains(doc, block) { - t.Errorf("%s has no ```%s block equal to %s", docPath, lang, path) + for _, rules := range ruleSets { + doc := readFile(t, rules.docPath) + for lang, path := range map[string]string{"lua": rules.luaPath, "yaml": rules.fluxPath} { + block := "```" + lang + "\n" + readFile(t, path) + "```\n" + if !strings.Contains(doc, block) { + t.Errorf("%s has no ```%s block equal to %s", rules.docPath, lang, path) + } } } } diff --git a/docs/how-to/manage-mcp-servers.md b/docs/how-to/manage-mcp-servers.md index b216fc8a0..92f57ec5a 100644 --- a/docs/how-to/manage-mcp-servers.md +++ b/docs/how-to/manage-mcp-servers.md @@ -225,6 +225,66 @@ These limits come from Coder, and the operator cannot change them: - Coder has no address guard for MCP traffic. A server URL can point at a cluster-internal or private address. `auth.type: none` needs no Secret, so only the control plane opt-in and RBAC limit it. - Coder's MCP client sets the API key, the header values and users' bearer tokens on every request, also after a redirect to another origin. A Secret's destination list therefore means "this origin and every place that it sends Coder". +## 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: + +| Conditions | Argo CD | Flux | +| --- | --- | --- | +| no status, or a stale `observedGeneration` | `Progressing` | `InProgress` (`Unknown` before the first status) | +| `Reconciling=True` | `Progressing` | `InProgress` | +| `Ready=True` | `Healthy` | `Current` | +| `Stalled=True` | `Degraded` | `Failed` | + +The Argo CD message is `: ` of the `Ready` condition. While the object is being deleted, Argo CD shows `Deleting the Coder MCP server config`. + +Add the script as the `argocd-cm` key `resource.customizations.health.coder.com_CoderMCPServer` (`config/gitops/argocd-health-codermcpserver.lua`): + +```lua +-- Argo CD health check for CoderMCPServer (coder.com/v1alpha1). +-- Install it as the argocd-cm key +-- resource.customizations.health.coder.com_CoderMCPServer. +-- docs/how-to/manage-mcp-servers.md copies this file. A test keeps the copy equal. +local hs = { status = "Progressing", message = "Waiting for the controller" } +if obj.metadata.deletionTimestamp ~= nil then + -- Argo CD shows deletionMessage, not message, for a resource being deleted. + hs.deletionMessage = "Deleting the Coder MCP server config" + hs.message = hs.deletionMessage + return hs +end +local st = obj.status +if st == nil or st.observedGeneration == nil or st.observedGeneration ~= obj.metadata.generation then + return hs +end +for _, c in ipairs(st.conditions or {}) 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 + if c.type == "Ready" and c.status == "True" then + hs.status = "Healthy" + elseif c.type == "Stalled" and c.status == "True" then + hs.status = "Degraded" + end +end +return hs +``` + +Add this entry (`config/gitops/flux-healthcheck-codermcpserver.yaml`) to `spec.healthCheckExprs` of a Flux v2.5+ Kustomization with `spec.wait: true`: + +```yaml +# Flux health check expressions for CoderMCPServer (coder.com/v1alpha1). +# Add this entry to spec.healthCheckExprs of a Flux v2.5+ Kustomization +# with spec.wait: true. +# 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')" + failed: "status.conditions.exists(c, c.type == 'Stalled' && c.status == 'True')" + current: "status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')" +``` + ## Grant access The operator acts as a Coder site owner. So each permission in the control plane's namespace maps to Coder power: