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/coderaiprovider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ type CoderAIProviderStatus struct {
// ProviderID is the Coder provider ID.
// +optional
ProviderID string `json:"providerID,omitempty"`
// Enabled is the enabled flag of the Coder provider at the controller's
// last read or update of it. It is empty while Coder holds no provider of
// this object, or before the first read.
// +optional
Enabled *bool `json:"enabled,omitempty"`
// CreateAttemptTime is set before every provider create request. While it
// is set and ProviderID is empty, a create request can be in flight or
// already committed in Coder.
Expand All @@ -115,7 +120,7 @@ type CoderAIProviderStatus struct {
// +kubebuilder:resource:path=coderaiproviders,scope=Namespaced
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.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"
// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 63 && self.metadata.name.matches('^[a-z0-9]+(-[a-z0-9]+)*$')",message="metadata.name is the Coder provider name: at most 63 lowercase letters, digits, and single hyphens between them"
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_coderaiproviders.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ spec:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.enabled
- jsonPath: .status.enabled
name: Enabled
type: boolean
- jsonPath: .status.conditions[?(@.type=="Ready")].status
Expand Down Expand Up @@ -245,6 +245,12 @@ spec:
description: SecretUID is the UID of the Secret that was read.
type: string
type: object
enabled:
description: |-
Enabled is the enabled flag of the Coder provider at the controller's
last read or update of it. It is empty while Coder holds no provider of
this object, or before the first read.
type: boolean
observedGeneration:
description: ObservedGeneration is the generation the controller last
processed.
Expand Down
35 changes: 35 additions & 0 deletions config/gitops/argocd-health-coderaiprovider.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- Argo CD health check for CoderAIProvider (coder.com/v1alpha1).
-- Install it as the argocd-cm key
-- resource.customizations.health.coder.com_CoderAIProvider.
-- docs/how-to/gitops.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 AI provider"
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
-- 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(conditions) 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
9 changes: 9 additions & 0 deletions config/gitops/flux-healthcheck-coderaiprovider.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Flux health check expressions for CoderAIProvider (coder.com/v1alpha1).
# Add this entry to spec.healthCheckExprs of a Flux v2.5+ Kustomization
# with spec.wait: true.
# docs/how-to/gitops.md copies this file. A test keeps the copy equal.
- apiVersion: coder.com/v1alpha1
kind: CoderAIProvider
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')"
89 changes: 70 additions & 19 deletions config/gitops/health_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 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 checks the GitOps health rules for CoderTemplateTest,
// CoderMCPServer and CoderAIProvider. The rules are data files, so this
// package has no production code.
package gitops_test

import (
Expand Down Expand Up @@ -29,6 +29,7 @@ type ruleSet struct {
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},
{"CoderAIProvider", "argocd-health-coderaiprovider.lua", "flux-healthcheck-coderaiprovider.yaml", "../../docs/how-to/gitops.md", aiProviderCases},
}

// fluxHealthCheck is one entry of a Flux Kustomization spec.healthCheckExprs.
Expand Down Expand Up @@ -128,10 +129,10 @@ func mcpServerObject(status map[string]any) map[string]any {
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 {
// readyReconcilingStalled returns the three conditions that the CoderMCPServer
// and CoderAIProvider controllers always write together, with one reason and
// message: ready, reconciling (neither ready nor stalled), or stalled.
func readyReconcilingStalled(state, reason string) map[string]any {
ready, reconciling, stalled := "False", "True", "False"
switch state {
case "ready":
Expand All @@ -140,7 +141,7 @@ func mcpServerStatus(state, reason string) map[string]any {
reconciling, stalled = "False", "True"
case "reconciling":
default:
panic("assertion failed: unknown MCP server fixture state " + state)
panic("assertion failed: unknown condition fixture state " + state)
}
condition := func(conditionType, status string) any {
return map[string]any{"type": conditionType, "status": status, "reason": reason, "message": "details", "observedGeneration": int64(2)}
Expand All @@ -152,31 +153,81 @@ func mcpServerStatus(state, reason string) map[string]any {
}

func mcpServerCases() []healthCase {
stale := mcpServerStatus("ready", "Applied")
stale := readyReconcilingStalled("ready", "Applied")
stale["observedGeneration"] = int64(1)
noConditions := map[string]any{"observedGeneration": int64(2)}
deleting := mcpServerObject(mcpServerStatus("ready", "Applied"))
deleting := mcpServerObject(readyReconcilingStalled("ready", "Applied"))
deleting["metadata"].(map[string]any)["deletionTimestamp"] = "2026-10-14T10:00:00Z"
deletingWait := mcpServerObject(mcpServerStatus("reconciling", "CoderUnavailable"))
deletingWait := mcpServerObject(readyReconcilingStalled("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", ""},
{"applied", mcpServerObject(readyReconcilingStalled("ready", "Applied")), "Healthy", "Applied: details", "Current", ""},
{"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", ""},
{"not allowed", mcpServerObject(readyReconcilingStalled("stalled", "MCPServersNotAllowed")), "Degraded", "MCPServersNotAllowed: details", "Failed", ""},
{"disabled externally", mcpServerObject(readyReconcilingStalled("stalled", "DisabledExternally")), "Degraded", "DisabledExternally: details", "Failed", ""},
{"credential not opted in", mcpServerObject(readyReconcilingStalled("stalled", "CredentialNotOptedIn")), "Degraded", "CredentialNotOptedIn: details", "Failed", ""},
{"slug conflict", mcpServerObject(readyReconcilingStalled("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"},
}
}

// aiProviderObject returns a CoderAIProvider at generation 2 with the given
// status.
func aiProviderObject(status map[string]any) map[string]any {
obj := map[string]any{
"apiVersion": "coder.com/v1alpha1",
"kind": "CoderAIProvider",
"metadata": map[string]any{"name": "openai", "namespace": "coder", "generation": int64(2)},
"spec": map[string]any{"type": "openai", "baseURL": "https://api.openai.com/v1"},
}
if status != nil {
obj["status"] = status
}
return obj
}

func aiProviderCases() []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 #321 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 := aiProviderObject(readyReconcilingStalled("ready", "Applied"))
deleting["metadata"].(map[string]any)["deletionTimestamp"] = "2026-10-30T10:00:00Z"
deletingWait := aiProviderObject(readyReconcilingStalled("reconciling", "CoderUnavailable"))
deletingWait["metadata"].(map[string]any)["deletionTimestamp"] = "2026-10-30T10:00:00Z"
return []healthCase{
{"no status", aiProviderObject(nil), "Progressing", "Waiting for the controller", "Unknown", ""},
{"stale observedGeneration", aiProviderObject(stale), "Progressing", "Waiting for the controller", "InProgress", ""},
{"no conditions", aiProviderObject(noConditions), "Progressing", "Waiting for the controller", "InProgress", ""},
{"applied", aiProviderObject(readyReconcilingStalled("ready", "Applied")), "Healthy", "Applied: details", "Current", ""},
{"stale conditions", aiProviderObject(interrupted), "Progressing", "Waiting for the controller", "InProgress", ""},
{"control plane not ready", aiProviderObject(readyReconcilingStalled("reconciling", "ControlPlaneNotReady")), "Progressing", "ControlPlaneNotReady: details", "InProgress", ""},
{"confirming a create", aiProviderObject(readyReconcilingStalled("reconciling", "ConfirmingCreate")), "Progressing", "ConfirmingCreate: details", "InProgress", ""},
// A disable that failed retries with the credential reason.
{"disable retrying", aiProviderObject(readyReconcilingStalled("reconciling", "CredentialNotOptedIn")), "Progressing", "CredentialNotOptedIn: details", "InProgress", ""},
{"credential kill switch", aiProviderObject(readyReconcilingStalled("stalled", "CredentialNotOptedIn")), "Degraded", "CredentialNotOptedIn: details", "Failed", ""},
{"name conflict", aiProviderObject(readyReconcilingStalled("stalled", "NameConflict")), "Degraded", "NameConflict: details", "Failed", ""},
{"name reserved by env", aiProviderObject(readyReconcilingStalled("stalled", "NameReservedByEnv")), "Degraded", "NameReservedByEnv: details", "Failed", ""},
{"disabled externally", aiProviderObject(readyReconcilingStalled("stalled", "DisabledExternally")), "Degraded", "DisabledExternally: details", "Failed", ""},
// Flux has no deletion rule: it waits for pruned objects separately.
{"being deleted", deleting, "Progressing", "Deleting the Coder AI provider", "Current", "Deleting the Coder AI provider"},
{"being deleted while Coder is down", deletingWait, "Progressing", "Deleting the Coder AI provider", "InProgress", "Deleting the Coder AI provider"},
}
}

func TestArgoCDHealth(t *testing.T) {
t.Parallel()
for _, rules := range ruleSets {
Expand Down
8 changes: 7 additions & 1 deletion dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ spec:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.enabled
- jsonPath: .status.enabled
name: Enabled
type: boolean
- jsonPath: .status.conditions[?(@.type=="Ready")].status
Expand Down Expand Up @@ -636,6 +636,12 @@ spec:
description: SecretUID is the UID of the Secret that was read.
type: string
type: object
enabled:
description: |-
Enabled is the enabled flag of the Coder provider at the controller's
last read or update of it. It is empty while Coder holds no provider of
this object, or before the first read.
type: boolean
observedGeneration:
description: ObservedGeneration is the generation the controller last
processed.
Expand Down
Loading
Loading