From da185924ede454a1efffa3230199725ac66cf27c Mon Sep 17 00:00:00 2001 From: LorenzBischof <1837725+LorenzBischof@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:08:56 +0200 Subject: [PATCH] feat: add healthchecks for TenantResource and GlobalTenantResource --- api/v1beta2/tenantresource_global.go | 1 + api/v1beta2/tenantresource_namespaced.go | 1 + api/v1beta2/tenantresource_types.go | 35 +++ api/v1beta2/zz_generated.deepcopy.go | 20 ++ charts/capsule/README.md | 8 + ...sule.clastix.io_globaltenantresources.yaml | 64 ++++ .../capsule.clastix.io_tenantresources.yaml | 64 ++++ charts/capsule/templates/configuration.yaml | 32 ++ charts/capsule/values.schema.json | 66 ++++ charts/capsule/values.yaml | 37 +++ cmd/controller/main.go | 2 + docs/replication-healthchecks.md | 120 ++++++++ e2e/replications_healthchecks_test.go | 284 ++++++++++++++++++ go.mod | 8 +- go.sum | 102 +------ internal/controllers/resources/global.go | 24 +- internal/controllers/resources/health.go | 161 ++++++++++ internal/controllers/resources/namespaced.go | 24 +- .../metrics/global_tenantresource_recorder.go | 2 +- internal/metrics/tenantresource_recorder.go | 2 +- internal/webhook/route/tenantresource.go | 24 ++ internal/webhook/tenantresource/validating.go | 93 ++++++ pkg/api/meta/conditions.go | 32 +- pkg/api/meta/reference.go | 12 + pkg/runtime/health/health.go | 212 +++++++++++++ pkg/runtime/health/health_test.go | 239 +++++++++++++++ 26 files changed, 1559 insertions(+), 110 deletions(-) create mode 100644 docs/replication-healthchecks.md create mode 100644 e2e/replications_healthchecks_test.go create mode 100644 internal/controllers/resources/health.go create mode 100644 internal/webhook/route/tenantresource.go create mode 100644 internal/webhook/tenantresource/validating.go create mode 100644 pkg/runtime/health/health.go create mode 100644 pkg/runtime/health/health_test.go diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index 068d87323..d0f50c587 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -42,6 +42,7 @@ type GlobalTenantResourceStatus struct { // +kubebuilder:resource:scope=Cluster,shortName=gtr // +kubebuilder:printcolumn:name="Items",type="integer",JSONPath=".status.size",description="The total amount of items being replicated" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status for the tenant" +// +kubebuilder:printcolumn:name="Healthy",type="string",JSONPath=".status.conditions[?(@.type==\"Healthy\")].status",description="Health Status of the replicated objects" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message for the tenant" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index 98925834e..3589739f6 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -28,6 +28,7 @@ type TenantResourceStatus struct { // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Items",type="integer",JSONPath=".status.size",description="The total amount of items being replicated" // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status for the tenant" +// +kubebuilder:printcolumn:name="Healthy",type="string",JSONPath=".status.conditions[?(@.type==\"Healthy\")].status",description="Health Status of the replicated objects" // +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message for the tenant" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" diff --git a/api/v1beta2/tenantresource_types.go b/api/v1beta2/tenantresource_types.go index 3b9b61195..3a62acd80 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -58,6 +58,41 @@ type TenantResourceCommonSpec struct { Cordoned *bool `json:"cordoned,omitempty"` // Defines the rules to select targeting Namespace, along with the objects that must be replicated. Resources []ResourceSpec `json:"resources"` + // Define health checks against the replicated resources. Objects matching an + // entry (by apiVersion and kind) are evaluated with the given CEL expressions; + // objects without a matching entry are evaluated using kstatus. The outcome is + // aggregated into the Healthy condition and is re-evaluated on each resyncPeriod. + // +optional + HealthChecks []HealthCheckSpec `json:"healthChecks,omitempty"` +} + +// HealthCheckSpec defines how objects of a given apiVersion/kind are evaluated +// for health. The CEL expressions follow the Flux healthCheckExprs convention: +// the object's top-level fields (status, metadata, spec, ...) are exposed +// directly, so an expression such as +// `status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')` +// can be used as-is. Expressions must evaluate to a boolean. +type HealthCheckSpec struct { + // APIVersion of the objects this health check applies to (e.g. "apps/v1"). + // +required + APIVersion string `json:"apiVersion"` + // Kind of the objects this health check applies to (e.g. "Deployment"). + // +required + Kind string `json:"kind"` + // Current is a CEL expression which is true when the object has reached its + // desired state (healthy). + // +optional + Current string `json:"current,omitempty"` + // Failed is a CEL expression which is true when the object has permanently + // failed (unhealthy). + // +optional + Failed string `json:"failed,omitempty"` + // InProgress is a CEL expression which is true when the object is still + // progressing. It is evaluated after failed and before current, so a matching + // inProgress holds back a premature healthy verdict. Optional; when omitted, + // "not failed and not current" is already treated as in progress. + // +optional + InProgress string `json:"inProgress,omitempty"` } type TenantResourceCommonSpecSettings struct { diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index a5d24bc43..320685a02 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -758,6 +758,21 @@ func (in *GlobalTenantResourceStatus) DeepCopy() *GlobalTenantResourceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheckSpec) DeepCopyInto(out *HealthCheckSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckSpec. +func (in *HealthCheckSpec) DeepCopy() *HealthCheckSpec { + if in == nil { + return nil + } + out := new(HealthCheckSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressOptions) DeepCopyInto(out *IngressOptions) { *out = *in @@ -1989,6 +2004,11 @@ func (in *TenantResourceCommonSpec) DeepCopyInto(out *TenantResourceCommonSpec) (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.HealthChecks != nil { + in, out := &in.HealthChecks, &out.HealthChecks + *out = make([]HealthCheckSpec, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceCommonSpec. diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 065d9ca06..1b8ddcf39 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -374,6 +374,14 @@ The following Values have changed key or Value: | webhooks.hooks.services.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.services.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.tenantResourceObjects | object | `{}` | Deprecated, use webhooks.hooks.replications instead | +| webhooks.hooks.tenantresources | object | `{"enabled":true,"failurePolicy":"Fail","matchConditions":[],"matchPolicy":"Equivalent","namespaceSelector":{},"objectSelector":{},"rules":[{"apiGroups":["capsule.clastix.io"],"apiVersions":["v1beta2"],"operations":["CREATE","UPDATE"],"resources":["tenantresources"],"scope":"Namespaced"},{"apiGroups":["capsule.clastix.io"],"apiVersions":["v1beta2"],"operations":["CREATE","UPDATE"],"resources":["globaltenantresources"],"scope":"Cluster"}]}` | Webhook for TenantResource/GlobalTenantResource healthChecks validation | +| webhooks.hooks.tenantresources.enabled | bool | `true` | Enable the Hook | +| webhooks.hooks.tenantresources.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | +| webhooks.hooks.tenantresources.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.tenantresources.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.tenantresources.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | +| webhooks.hooks.tenantresources.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | +| webhooks.hooks.tenantresources.rules | list | `[{"apiGroups":["capsule.clastix.io"],"apiVersions":["v1beta2"],"operations":["CREATE","UPDATE"],"resources":["tenantresources"],"scope":"Namespaced"},{"apiGroups":["capsule.clastix.io"],"apiVersions":["v1beta2"],"operations":["CREATE","UPDATE"],"resources":["globaltenantresources"],"scope":"Cluster"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | | webhooks.hooks.tenants.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.tenants.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.tenants.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 02da88455..058cb76d2 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -25,6 +25,10 @@ spec: jsonPath: .status.conditions[?(@.type=="Ready")].status name: Ready type: string + - description: Health Status of the replicated objects + jsonPath: .status.conditions[?(@.type=="Healthy")].status + name: Healthy + type: string - description: Reconcile Message for the tenant jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status @@ -83,6 +87,51 @@ spec: - name type: object type: array + healthChecks: + description: |- + Define health checks against the replicated resources. Objects matching an + entry (by apiVersion and kind) are evaluated with the given CEL expressions; + objects without a matching entry are evaluated using kstatus. The outcome is + aggregated into the Healthy condition and is re-evaluated on each resyncPeriod. + items: + description: |- + HealthCheckSpec defines how objects of a given apiVersion/kind are evaluated + for health. The CEL expressions follow the Flux healthCheckExprs convention: + the object's top-level fields (status, metadata, spec, ...) are exposed + directly, so an expression such as + `status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')` + can be used as-is. Expressions must evaluate to a boolean. + properties: + apiVersion: + description: APIVersion of the objects this health check applies + to (e.g. "apps/v1"). + type: string + current: + description: |- + Current is a CEL expression which is true when the object has reached its + desired state (healthy). + type: string + failed: + description: |- + Failed is a CEL expression which is true when the object has permanently + failed (unhealthy). + type: string + inProgress: + description: |- + InProgress is a CEL expression which is true when the object is still + progressing. It is evaluated after failed and before current, so a matching + inProgress holds back a premature healthy verdict. Optional; when omitted, + "not failed and not current" is already treated as in progress. + type: string + kind: + description: Kind of the objects this health check applies to + (e.g. "Deployment"). + type: string + required: + - apiVersion + - kind + type: object + type: array pruningOnDelete: default: true description: |- @@ -547,6 +596,21 @@ spec: description: Indicates wether the resource was created or adopted type: boolean + healthMessage: + description: |- + HealthMessage is a human readable message with details about the health of + this individual replicated object. + type: string + healthy: + description: |- + Healthy reports the health of this individual replicated object, as evaluated + via the resource's healthChecks (CEL) or the kstatus fallback. One of True, + False, Unknown. Unset when no health evaluation has run yet. + enum: + - "True" + - "False" + - Unknown + type: string lastApply: description: |- An opaque value that represents the internal version of this object that can diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 31b730c85..65e4b13f2 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -23,6 +23,10 @@ spec: jsonPath: .status.conditions[?(@.type=="Ready")].status name: Ready type: string + - description: Health Status of the replicated objects + jsonPath: .status.conditions[?(@.type=="Healthy")].status + name: Healthy + type: string - description: Reconcile Message for the tenant jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status @@ -83,6 +87,51 @@ spec: - name type: object type: array + healthChecks: + description: |- + Define health checks against the replicated resources. Objects matching an + entry (by apiVersion and kind) are evaluated with the given CEL expressions; + objects without a matching entry are evaluated using kstatus. The outcome is + aggregated into the Healthy condition and is re-evaluated on each resyncPeriod. + items: + description: |- + HealthCheckSpec defines how objects of a given apiVersion/kind are evaluated + for health. The CEL expressions follow the Flux healthCheckExprs convention: + the object's top-level fields (status, metadata, spec, ...) are exposed + directly, so an expression such as + `status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')` + can be used as-is. Expressions must evaluate to a boolean. + properties: + apiVersion: + description: APIVersion of the objects this health check applies + to (e.g. "apps/v1"). + type: string + current: + description: |- + Current is a CEL expression which is true when the object has reached its + desired state (healthy). + type: string + failed: + description: |- + Failed is a CEL expression which is true when the object has permanently + failed (unhealthy). + type: string + inProgress: + description: |- + InProgress is a CEL expression which is true when the object is still + progressing. It is evaluated after failed and before current, so a matching + inProgress holds back a premature healthy verdict. Optional; when omitted, + "not failed and not current" is already treated as in progress. + type: string + kind: + description: Kind of the objects this health check applies to + (e.g. "Deployment"). + type: string + required: + - apiVersion + - kind + type: object + type: array pruningOnDelete: default: true description: |- @@ -482,6 +531,21 @@ spec: description: Indicates wether the resource was created or adopted type: boolean + healthMessage: + description: |- + HealthMessage is a human readable message with details about the health of + this individual replicated object. + type: string + healthy: + description: |- + Healthy reports the health of this individual replicated object, as evaluated + via the resource's healthChecks (CEL) or the kstatus fallback. One of True, + False, Unknown. Unset when no health evaluation has run yet. + enum: + - "True" + - "False" + - Unknown + type: string lastApply: description: |- An opaque value that represents the internal version of this object that can diff --git a/charts/capsule/templates/configuration.yaml b/charts/capsule/templates/configuration.yaml index 7f05d3af7..a48b86a94 100644 --- a/charts/capsule/templates/configuration.yaml +++ b/charts/capsule/templates/configuration.yaml @@ -889,6 +889,38 @@ spec: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} + {{- with .Values.webhooks.hooks.tenantresources }} + {{- if .enabled }} + {{- $any = true }} + - name: tenantresources.validating.projectcapsule.dev + {{- with .opts }} + opts: + {{- toYaml . | nindent 10 }} + {{- end }} + admissionReviewVersions: + - v1 + - v1beta1 + path: "/tenantresources/validating" + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 10 }} + {{- end }} + rules: + {{- toYaml .rules | nindent 10 }} + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} + {{- end }} + {{- end }} {{- with .Values.webhooks.hooks.calculations }} {{- if .enabled }} {{- $any = true }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index 4aec85e95..137438b95 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -2251,6 +2251,72 @@ "description": "Deprecated, use webhooks.hooks.replications instead", "type": "object" }, + "tenantresources": { + "description": "Webhook for TenantResource/GlobalTenantResource healthChecks validation", + "type": "object", + "properties": { + "enabled": { + "description": "Enable the Hook", + "type": "boolean" + }, + "failurePolicy": { + "description": "[FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)", + "type": "string" + }, + "matchConditions": { + "description": "[MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "array" + }, + "matchPolicy": { + "description": "[MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "string" + }, + "namespaceSelector": { + "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", + "type": "object" + }, + "objectSelector": { + "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", + "type": "object" + }, + "rules": { + "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", + "type": "array", + "items": { + "type": "object", + "properties": { + "apiGroups": { + "type": "array", + "items": { + "type": "string" + } + }, + "apiVersions": { + "type": "array", + "items": { + "type": "string" + } + }, + "operations": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope": { + "type": "string" + } + } + } + } + } + }, "tenants": { "type": "object", "properties": { diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index dc4375138..f0d5aab20 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -589,6 +589,43 @@ webhooks: - globalcustomquotas scope: 'Cluster' + # -- Webhook for TenantResource/GlobalTenantResource healthChecks validation + tenantresources: + # -- Enable the Hook + enabled: true + # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) + failurePolicy: Fail + # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchPolicy: Equivalent + # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) + objectSelector: {} + # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) + namespaceSelector: {} + # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchConditions: [] + # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) + rules: + - apiGroups: + - capsule.clastix.io + apiVersions: + - v1beta2 + operations: + - CREATE + - UPDATE + resources: + - tenantresources + scope: 'Namespaced' + - apiGroups: + - capsule.clastix.io + apiVersions: + - v1beta2 + operations: + - CREATE + - UPDATE + resources: + - globaltenantresources + scope: 'Cluster' + # -- Webhook for Custom Quota Calculations ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) calculations: # -- Enable the Hook diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 0e19ba9a6..9f38da2c7 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -81,6 +81,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/serviceaccounts" tenantmutation "github.com/projectcapsule/capsule/internal/webhook/tenant/mutation" tenantvalidation "github.com/projectcapsule/capsule/internal/webhook/tenant/validation" + tenantresourcewebhook "github.com/projectcapsule/capsule/internal/webhook/tenantresource" "github.com/projectcapsule/capsule/pkg/runtime/configuration" evt "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" @@ -685,6 +686,7 @@ func main() { ), ), route.RulesValidating(cfg), + route.TenantResourceValidation(tenantresourcewebhook.HealthChecksValidationHandler()), ) nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion) diff --git a/docs/replication-healthchecks.md b/docs/replication-healthchecks.md new file mode 100644 index 000000000..90e4cc216 --- /dev/null +++ b/docs/replication-healthchecks.md @@ -0,0 +1,120 @@ +# Health checks for `TenantResource` / `GlobalTenantResource` + +Both `TenantResource` and `GlobalTenantResource` expose a `Ready` condition that +reflects whether the declared manifests were successfully **applied**. This says +nothing about whether the **resulting objects** are actually healthy (e.g. a +replicated `Deployment` may be applied but never become available). + +The optional `spec.healthChecks` field adds a second condition, `Healthy`, which +reflects the health of the replicated objects. + +## How health is evaluated + +Every successfully-applied item in `status.processedItems` is evaluated: + +1. **Custom CEL expressions** — if a `healthChecks` entry matches the object's + `apiVersion` and `kind`, the object is classified using its expressions, in + order: + - `failed` is true → **unhealthy** + - otherwise `inProgress` is true → **in progress** (holds back a premature + healthy verdict while the object is still settling) + - otherwise `current` is true → **healthy** + - otherwise → **in progress** +2. **kstatus (default)** — objects without a matching entry are evaluated with + [kstatus], which understands the built-in workloads (Deployments, Jobs, + StatefulSets, …) and the `Ready`-condition convention used by most custom + resources. `Current` → healthy, `Failed` → unhealthy, anything else → in + progress. + +The per-object outcomes are aggregated into the `Healthy` condition: + +| Aggregate | `Healthy` status | reason | +| -------------------------------- | ---------------- | ------------- | +| any object unhealthy | `False` | `Failed` | +| any object in progress | `Unknown` | `Progressing` | +| all objects healthy / nothing to check | `True` | `Succeeded` | + +While the resource is reconciling, cordoned, gated on `dependsOn`, or being +deleted, `Healthy` is set to `Unknown`. + +`Healthy` is independent of `Ready`: it does **not** gate `Ready`, and +`dependsOn` still only waits on `Ready`. + +## CEL variable model + +The expressions follow the Flux [`healthCheckExprs`][flux] convention: the +object's top-level fields are exposed directly, so an expression can reference +`status`, `metadata`, `spec`, `data`, etc. For example, the issue's original +request works as-is: + +```cel +status.conditions.filter(e, e.type == 'Synced').all(e, e.status == 'True') +``` + +Notes: + +- Expressions must evaluate to a **boolean**. +- Referencing an absent field (e.g. `status` before the controller writes it) + does not error the reconcile — it simply does not match, so the object is + treated as *in progress*. +- The Secret `type` field is not exposed, because `type` collides with the CEL + built-in identifier. + +## Example + +```yaml +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: example +spec: + resyncPeriod: 60s + tenantSelector: + matchLabels: + env: prod + healthChecks: + - apiVersion: example.com/v1 + kind: Database + current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')" + failed: "status.conditions.exists(e, e.type == 'Ready' && e.status == 'False' && e.reason == 'Fatal')" + resources: + - namespaceSelector: {} + rawItems: + - apiVersion: example.com/v1 + kind: Database + metadata: + name: shared-db + spec: {} +``` + +## Where to look + +- **`status.conditions[type=Healthy]`** — the aggregate condition. Its message + names up to the first few offending objects, e.g. + `3 unhealthy: team-a/shared-db (Database), team-b/shared-db (Database), team-c/shared-db (Database) and 5 more`. +- **`status.processedItems[*].healthy` / `healthMessage`** — the complete, + non-truncated per-object health. Use this to pinpoint every unhealthy replica + on large fleets, e.g. `kubectl get gtr example -o json | jq '.status.processedItems[] | select(.healthy=="False")'`. +- **Metrics** — the `Healthy` condition is exported alongside `Ready` on the + `capsule_global_resource_condition` / `capsule_resource_condition` gauges. + +## Re-evaluation cadence + +Health is recomputed on every reconcile, and the controller requeues at least +every `spec.resyncPeriod`. There are no dynamic watches on the checked GVKs, so a +change in an object's health becomes visible within one resync period rather than +instantly. + +## Validation + +`spec.healthChecks` expressions are compiled by a validating admission webhook, +so invalid expressions (or entries declaring neither `current` nor `failed`) are +rejected at `kubectl apply` time. If the webhook is disabled, a compile error +instead surfaces at runtime as `Healthy=False` with the error in the message. + +The RBAC of the impersonated ServiceAccount (or the controller ServiceAccount +when none is set) must allow `get` on the checked GVKs; otherwise the objects are +reported as *in progress*. + +[kstatus]: https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus +[flux]: https://fluxcd.io/flux/components/kustomize/kustomizations/#health-check-expressions diff --git a/e2e/replications_healthchecks_test.go b/e2e/replications_healthchecks_test.go new file mode 100644 index 000000000..534b2b76b --- /dev/null +++ b/e2e/replications_healthchecks_test.go @@ -0,0 +1,284 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" + apimeta "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" +) + +var _ = Describe("Replication healthChecks", Ordered, Label("replications", "healthchecks"), func() { + var ( + ctx context.Context + tnt *capsulev1beta2.Tenant + tenantOwner rbac.UserSpec + baseNamespace string + namespaces []string + ) + + // expectGlobalHealthy polls the GlobalTenantResource's Healthy condition. + expectGlobalHealthy := func(name string, status metav1.ConditionStatus, msgContains string) { + Eventually(func(g Gomega) { + current := &capsulev1beta2.GlobalTenantResource{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name}, current)).To(Succeed()) + + cond := current.Status.Conditions.GetConditionByType(apimeta.HealthyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(status)) + + if msgContains != "" { + g.Expect(cond.Message).To(ContainSubstring(msgContains)) + } + }, 2*defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + expectTenantHealthy := func(namespace, name string, status metav1.ConditionStatus) { + Eventually(func(g Gomega) { + current := &capsulev1beta2.TenantResource{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, current)).To(Succeed()) + + cond := current.Status.Conditions.GetConditionByType(apimeta.HealthyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(status)) + }, 2*defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + BeforeEach(func() { + ctx = context.Background() + tenantOwner = rbac.UserSpec{Name: "e2e-hc-owner", Kind: rbac.OwnerKind("User")} + baseNamespace = "e2e-hc-system" + namespaces = []string{"e2e-hc-one", "e2e-hc-two"} + + tnt = &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-hc-tenant", + Labels: map[string]string{"hc": "true", "env": "e2e"}, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{{ + CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: tenantOwner}, + }}, + }, + } + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(ctx, tnt) + }).Should(Succeed()) + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + + for _, ns := range append(append([]string{}, namespaces...), baseNamespace) { + namespace := NewNamespace(ns, map[string]string{apimeta.TenantLabel: tnt.GetName()}) + NamespaceCreation(namespace, tenantOwner, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, namespace).Should(Succeed()) + } + }) + + AfterEach(func() { + Eventually(func() error { + list := &capsulev1beta2.GlobalTenantResourceList{} + if err := k8sClient.List(ctx, list, client.MatchingLabels{"e2e.capsule.dev/test-suite": "true"}); err != nil { + return err + } + + for i := range list.Items { + if err := k8sClient.Delete(ctx, &list.Items[i]); client.IgnoreNotFound(err) != nil { + return err + } + } + + return nil + }, "30s", "5s").Should(Succeed()) + + for _, ns := range append([]string{baseNamespace}, namespaces...) { + ForceDeleteNamespace(ctx, ns) + } + + EventuallyDeletion(tnt) + }) + + // newConfigMapGTR builds a GlobalTenantResource replicating a ConfigMap to each + // tenant namespace, with a health check driven by the ConfigMap's own data. + newConfigMapGTR := func(name string, data map[string]string, checks []capsulev1beta2.HealthCheckSpec) *capsulev1beta2.GlobalTenantResource { + return &capsulev1beta2.GlobalTenantResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"e2e.capsule.dev/test-suite": "true"}, + }, + Spec: capsulev1beta2.GlobalTenantResourceSpec{ + Scope: api.ResourceScopeNamespace, + TenantSelector: metav1.LabelSelector{MatchLabels: map[string]string{"hc": "true"}}, + TenantResourceCommonSpec: capsulev1beta2.TenantResourceCommonSpec{ + ResyncPeriod: metav1.Duration{Duration: 5 * time.Second}, + PruningOnDelete: ptr.To(true), + HealthChecks: checks, + Resources: []capsulev1beta2.ResourceSpec{{ + RawItems: []capsulev1beta2.RawExtension{{ + RawExtension: runtime.RawExtension{ + Object: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: name}, + Data: data, + }, + }, + }}, + }}, + }, + }, + } + } + + It("reports Healthy via custom CEL expressions and flips to unhealthy", func() { + checks := []capsulev1beta2.HealthCheckSpec{{ + APIVersion: "v1", + Kind: "ConfigMap", + Current: "data['ready'] == 'true'", + Failed: "data['ready'] == 'false'", + }} + + gtr := newConfigMapGTR("gtr-hc-cel", map[string]string{"ready": "true"}, checks) + EventuallyCreation(func() error { return k8sClient.Create(ctx, gtr) }).Should(Succeed()) + + expectGlobalHealthy("gtr-hc-cel", metav1.ConditionTrue, "healthy") + + // Drive the replicated objects unhealthy by flipping the source data. + Eventually(func() error { + current := &capsulev1beta2.GlobalTenantResource{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: "gtr-hc-cel"}, current); err != nil { + return err + } + + current.Spec.Resources[0].RawItems[0] = capsulev1beta2.RawExtension{ + RawExtension: runtime.RawExtension{ + Object: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "gtr-hc-cel"}, + Data: map[string]string{"ready": "false"}, + }, + }, + } + + return k8sClient.Update(ctx, current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + expectGlobalHealthy("gtr-hc-cel", metav1.ConditionFalse, "unhealthy") + }) + + It("reports Healthy via the kstatus default for a Deployment", func() { + deployment := &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, + ObjectMeta: metav1.ObjectMeta{Name: "gtr-hc-deploy"}, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "gtr-hc-deploy"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "gtr-hc-deploy"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "pause", + Image: "registry.k8s.io/pause:3.9", + }}, + }, + }, + }, + } + + gtr := &capsulev1beta2.GlobalTenantResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gtr-hc-deploy", + Labels: map[string]string{"e2e.capsule.dev/test-suite": "true"}, + }, + Spec: capsulev1beta2.GlobalTenantResourceSpec{ + Scope: api.ResourceScopeNamespace, + TenantSelector: metav1.LabelSelector{MatchLabels: map[string]string{"hc": "true"}}, + TenantResourceCommonSpec: capsulev1beta2.TenantResourceCommonSpec{ + ResyncPeriod: metav1.Duration{Duration: 5 * time.Second}, + PruningOnDelete: ptr.To(true), + Resources: []capsulev1beta2.ResourceSpec{{ + RawItems: []capsulev1beta2.RawExtension{{ + RawExtension: runtime.RawExtension{Object: deployment}, + }}, + }}, + }, + }, + } + + EventuallyCreation(func() error { return k8sClient.Create(ctx, gtr) }).Should(Succeed()) + + expectGlobalHealthy("gtr-hc-deploy", metav1.ConditionTrue, "") + }) + + It("rejects a GlobalTenantResource with an invalid CEL expression at admission", func() { + checks := []capsulev1beta2.HealthCheckSpec{{ + APIVersion: "v1", + Kind: "ConfigMap", + Current: "status.conditions[", // syntax error + }} + + gtr := newConfigMapGTR("gtr-hc-invalid", map[string]string{"ready": "true"}, checks) + + err := k8sClient.Create(ctx, gtr) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid spec.healthChecks")) + }) + + It("reports Healthy on a namespaced TenantResource via custom CEL expressions", func() { + checks := []capsulev1beta2.HealthCheckSpec{{ + APIVersion: "v1", + Kind: "ConfigMap", + Current: "data['ready'] == 'true'", + Failed: "data['ready'] == 'false'", + }} + + tr := &capsulev1beta2.TenantResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tr-hc-cel", + Namespace: baseNamespace, + Labels: map[string]string{"e2e.capsule.dev/test-suite": "true"}, + }, + Spec: capsulev1beta2.TenantResourceSpec{ + TenantResourceCommonSpec: capsulev1beta2.TenantResourceCommonSpec{ + ResyncPeriod: resyncPeriod, + PruningOnDelete: ptr.To(true), + HealthChecks: checks, + Resources: []capsulev1beta2.ResourceSpec{{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{apimeta.TenantLabel: tnt.GetName()}, + }, + RawItems: []capsulev1beta2.RawExtension{{ + RawExtension: runtime.RawExtension{ + Object: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "tr-hc-cel"}, + Data: map[string]string{"ready": "true"}, + }, + }, + }}, + }}, + }, + }, + } + + EventuallyCreation(func() error { return k8sClient.Create(ctx, tr) }).Should(Succeed()) + + expectTenantHealthy(baseNamespace, "tr-hc-cel", metav1.ConditionTrue) + }) +}) diff --git a/go.mod b/go.mod index cb8bc225c..3f17972f7 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.26.4 require ( filippo.io/age v1.3.1 github.com/BurntSushi/toml v1.6.0 + github.com/fluxcd/cli-utils v0.37.1-flux.1 github.com/fluxcd/pkg/apis/kustomize v1.15.0 github.com/fluxcd/pkg/ssa v0.64.0 github.com/go-logr/logr v1.4.3 github.com/go-sprout/sprout v1.0.3 + github.com/google/cel-go v0.26.1 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pkg/errors v0.9.1 @@ -18,9 +20,9 @@ require ( github.com/valyala/fasttemplate v1.2.2 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.28.0 + go.yaml.in/yaml/v2 v2.4.3 golang.org/x/sync v0.20.0 gomodules.xyz/jsonpatch/v2 v2.5.0 - gomodules.xyz/jsonpatch/v3 v3.0.1 k8s.io/api v0.35.5 k8s.io/apiextensions-apiserver v0.35.5 k8s.io/apimachinery v0.35.5 @@ -41,7 +43,6 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.3 // indirect @@ -49,7 +50,6 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fluxcd/cli-utils v0.37.1-flux.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.5.1 // indirect @@ -72,7 +72,6 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect @@ -112,7 +111,6 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect diff --git a/go.sum b/go.sum index 8b6975375..303fe8aab 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -14,41 +14,27 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0= -github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= -github.com/coredns/corefile-migration v1.0.29 h1:g4cPYMXXDDs9uLE2gFYrJaPBuUAR07eEMGyh9JBE13w= -github.com/coredns/corefile-migration v1.0.29/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -139,20 +125,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -191,18 +171,10 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= -github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= -github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -226,8 +198,6 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -243,7 +213,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -273,39 +242,22 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -314,75 +266,42 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gomodules.xyz/jsonpatch/v3 v3.0.1/go.mod h1:CBhndykehEwTOlEfnsfJwvkFQbSN8YZFr9M+cIHAJto= -gomodules.xyz/orderedmap v0.1.0/go.mod h1:g9/TPUCm1t2gwD3j3zfV8uylyYhVdCNSi+xCEIu7yTU= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -394,7 +313,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -410,8 +328,6 @@ k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE= k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY= k8s.io/client-go v0.35.5 h1:wUrgqVSmFRw75bgSHY7X0G/hZM/QYpV0Hg7SYYOYpFk= k8s.io/client-go v0.35.5/go.mod h1:Z0mDcAJsX1Y7RQfuQlJipiRtqf8Mhk2VDu1/JvRqdGo= -k8s.io/cluster-bootstrap v0.34.2 h1:oKckPeunVCns37BntcsxaOesDul32yzGd3DFLjW2fc8= -k8s.io/cluster-bootstrap v0.34.2/go.mod h1:f21byPR7X5nt12ivZi+J3pb4sG4SH6VySX8KAAJA8BY= k8s.io/component-base v0.35.5 h1:1y1xxfpFNkNi4RMi6bvPNN4aDr9VhOijtEfrqnhPijs= k8s.io/component-base v0.35.5/go.mod h1:n/+aL98XYINubqIu/Okh6mS/kZT2nMeN4IQkQR4VXRg= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= @@ -424,16 +340,10 @@ k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKW k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/cluster-api v1.12.2 h1:+b+M2IygfvFZJq7bsaloNakimMEVNf81zkGR1IiuxXs= -sigs.k8s.io/cluster-api v1.12.2/go.mod h1:2XuF/dmN3c/1VITb6DB44N5+Ecvsvd5KOWqrY9Q53nU= sigs.k8s.io/cluster-api v1.13.2 h1:NVdbVLmh6IyfdtENQAi80AijJf/FjfQLODz/6caDjlc= sigs.k8s.io/cluster-api v1.13.2/go.mod h1:h7cyiUh+N7sIBkSerqU8cDkYMtRlXVO1c5RoJE1p5+g= -sigs.k8s.io/controller-runtime v0.23.0 h1:Ubi7klJWiwEWqDY+odSVZiFA0aDSevOCXpa38yCSYu8= -sigs.k8s.io/controller-runtime v0.23.0/go.mod h1:DBOIr9NsprUqCZ1ZhsuJ0wAnQSIxY/C6VjZbmLgw0j0= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= -sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8= -sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= @@ -444,10 +354,6 @@ sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7 sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/internal/controllers/resources/global.go b/internal/controllers/resources/global.go index dd407c69c..e46330def 100644 --- a/internal/controllers/resources/global.go +++ b/internal/controllers/resources/global.go @@ -127,6 +127,10 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc var statusErr error + // Aggregate health condition computed after a successful apply (nil until then, + // meaning "Unknown" — e.g. while cordoned, gated on dependencies or deleting). + var healthCond *meta.Condition + //nolint:dupl defer func() { meta.RemoveReconcileTriggerAnnotation(tntResource) @@ -136,7 +140,7 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc reconcileErr = statusErr } - if uerr := r.updateStatus(ctx, tntResource, reconcileErr); uerr != nil { + if uerr := r.updateStatus(ctx, tntResource, reconcileErr, healthCond); uerr != nil { if caperrors.IgnoreGone(uerr) { err = nil @@ -224,6 +228,13 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc statusErr = r.reconcile(ctx, c, tntResource) + // Evaluate the health of the replicated objects (skipped while deleting, where + // items are being pruned and Healthy stays Unknown). + if tntResource.DeletionTimestamp.IsZero() { + cond := evaluateHealthCondition(ctx, c, tntResource, tntResource.Spec.HealthChecks, tntResource.Status.ProcessedItems) + healthCond = &cond + } + if len(tntResource.Status.ProcessedItems) > 0 { controllerutil.AddFinalizer(tntResource, meta.ControllerFinalizer) } else { @@ -625,6 +636,7 @@ func (r *globalResourceController) updateReconcilingStatus(ctx context.Context, latest.Status.ServiceAccount = instance.Status.ServiceAccount latest.Status.Conditions.UpdateConditionByType(meta.NewReadyConditionReconcilingReason(instance)) + latest.Status.Conditions.UpdateConditionByType(meta.NewHealthyConditionUnknown(instance)) if err := r.client.Status().Update(ctx, latest); err != nil { if apierrors.IsNotFound(err) { @@ -641,7 +653,7 @@ func (r *globalResourceController) updateReconcilingStatus(ctx context.Context, }) } -func (r *globalResourceController) updateStatus(ctx context.Context, instance *capsulev1beta2.GlobalTenantResource, reconcileError error) error { +func (r *globalResourceController) updateStatus(ctx context.Context, instance *capsulev1beta2.GlobalTenantResource, reconcileError error, healthCondition *meta.Condition) error { instance.Status.UpdateStats() return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { @@ -678,6 +690,14 @@ func (r *globalResourceController) updateStatus(ctx context.Context, instance *c latest.Status.Conditions.UpdateConditionByType(cordonedCondition) + // Set Healthy Condition. A nil condition means health was not evaluated + // this reconcile (cordoned, gated on dependencies or deleting) -> Unknown. + if healthCondition != nil { + latest.Status.Conditions.UpdateConditionByType(*healthCondition) + } else { + latest.Status.Conditions.UpdateConditionByType(meta.NewHealthyConditionUnknown(instance)) + } + if err := r.client.Status().Update(ctx, latest); err != nil { return err } diff --git a/internal/controllers/resources/health.go b/internal/controllers/resources/health.go new file mode 100644 index 000000000..453ff88c3 --- /dev/null +++ b/internal/controllers/resources/health.go @@ -0,0 +1,161 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "context" + "fmt" + "sort" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/health" +) + +// maxUnhealthyInMessage caps how many offending objects are named in the Healthy +// condition message. The complete, non-truncated per-item health is recorded on +// status.processedItems. +const maxUnhealthyInMessage = 3 + +// evaluateHealthCondition evaluates the health of every successfully-applied item, +// records the per-item health on each entry (mutating items in place), and returns +// the aggregate Healthy condition for obj. +// +// The impersonated client is used to fetch live objects so RBAC stays consistent +// with the apply. A compile error in the health checks becomes a False/Failed +// condition rather than failing the reconcile. +func evaluateHealthCondition( + ctx context.Context, + c client.Client, + obj client.Object, + checks []capsulev1beta2.HealthCheckSpec, + items meta.ProcessedItems, +) meta.Condition { + checker, err := health.NewChecker(checks) + if err != nil { + cond := meta.NewHealthyCondition(obj) + cond.Status = metav1.ConditionFalse + cond.Reason = meta.FailedReason + cond.Message = fmt.Sprintf("invalid healthChecks: %v", err) + + return cond + } + + var ( + unhealthy []string + progressing int + evaluated int + ) + + for i := range items { + item := &items[i] + + // Only evaluate items whose apply succeeded; the rest are covered by Ready. + if item.Status != metav1.ConditionTrue { + item.Healthy = metav1.ConditionUnknown + item.HealthMessage = "apply not completed" + + continue + } + + res := checkItem(ctx, c, checker, item) + evaluated++ + + //nolint:exhaustive + switch res.Status { + case health.StatusHealthy: + item.Healthy = metav1.ConditionTrue + case health.StatusUnhealthy: + item.Healthy = metav1.ConditionFalse + + unhealthy = append(unhealthy, itemDisplayName(item)) + default: + item.Healthy = metav1.ConditionUnknown + + progressing++ + } + + item.HealthMessage = res.Message + } + + return aggregateHealth(obj, evaluated, unhealthy, progressing) +} + +// checkItem fetches the live object referenced by item and evaluates its health. +// A get error is treated as in-progress, since the object may not be visible yet. +func checkItem( + ctx context.Context, + c client.Client, + checker *health.Checker, + item *meta.ObjectReferenceStatus, +) health.Result { + live := &unstructured.Unstructured{} + live.SetGroupVersionKind(item.GetGVK()) + + if err := c.Get(ctx, types.NamespacedName{Name: item.Name, Namespace: item.Namespace}, live); err != nil { + return health.Result{Status: health.StatusProgressing, Message: err.Error()} + } + + return checker.Check(live) +} + +// aggregateHealth folds the per-item outcomes into a single Healthy condition: +// any unhealthy -> False/Failed, any progressing -> Unknown/Progressing, otherwise +// True/Succeeded (including when there is nothing to evaluate). +func aggregateHealth(obj client.Object, evaluated int, unhealthy []string, progressing int) meta.Condition { + cond := meta.NewHealthyCondition(obj) + + switch { + case len(unhealthy) > 0: + sort.Strings(unhealthy) + + cond.Status = metav1.ConditionFalse + cond.Reason = meta.FailedReason + cond.Message = unhealthyMessage(unhealthy) + case progressing > 0: + cond.Status = metav1.ConditionUnknown + cond.Reason = meta.ProgressingReason + cond.Message = fmt.Sprintf("%d object(s) still progressing", progressing) + case evaluated == 0: + cond.Message = "no objects to evaluate" + default: + cond.Message = fmt.Sprintf("all %d object(s) are healthy", evaluated) + } + + return cond +} + +// unhealthyMessage renders the first maxUnhealthyInMessage offending objects, +// appending an "and N more" suffix when the list is longer. +func unhealthyMessage(unhealthy []string) string { + shown := unhealthy + suffix := "" + + if len(unhealthy) > maxUnhealthyInMessage { + shown = unhealthy[:maxUnhealthyInMessage] + suffix = fmt.Sprintf(" and %d more", len(unhealthy)-maxUnhealthyInMessage) + } + + return fmt.Sprintf("%d unhealthy: %s%s", len(unhealthy), strings.Join(shown, ", "), suffix) +} + +// itemDisplayName renders a stable, human-readable identifier for a processed item. +func itemDisplayName(item *meta.ObjectReferenceStatus) string { + name := item.Name + if item.Namespace != "" { + name = item.Namespace + "/" + item.Name + } + + if item.Kind != "" { + return fmt.Sprintf("%s (%s)", name, item.Kind) + } + + return name +} diff --git a/internal/controllers/resources/namespaced.go b/internal/controllers/resources/namespaced.go index 5db871b85..11edef87a 100644 --- a/internal/controllers/resources/namespaced.go +++ b/internal/controllers/resources/namespaced.go @@ -164,6 +164,10 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re var statusErr error + // Aggregate health condition computed after a successful apply (nil until then, + // meaning "Unknown" — e.g. while cordoned, gated on dependencies or deleting). + var healthCond *meta.Condition + //nolint:dupl defer func() { meta.RemoveReconcileTriggerAnnotation(tntResource) @@ -173,7 +177,7 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re reconcileErr = statusErr } - if uerr := r.updateStatus(ctx, tntResource, reconcileErr); uerr != nil { + if uerr := r.updateStatus(ctx, tntResource, reconcileErr, healthCond); uerr != nil { if caperrors.IgnoreGone(uerr) { err = nil @@ -264,6 +268,13 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re statusErr = r.reconcile(ctx, c, tntResource) + // Evaluate the health of the replicated objects (skipped while deleting, where + // items are being pruned and Healthy stays Unknown). + if tntResource.DeletionTimestamp.IsZero() { + cond := evaluateHealthCondition(ctx, c, tntResource, tntResource.Spec.HealthChecks, tntResource.Status.ProcessedItems) + healthCond = &cond + } + if len(tntResource.Status.ProcessedItems) > 0 { controllerutil.AddFinalizer(tntResource, meta.ControllerFinalizer) } else { @@ -633,6 +644,7 @@ func (r *namespacedResourceController) updateReconcilingStatus(ctx context.Conte latest.Status.ServiceAccount = instance.Status.ServiceAccount latest.Status.Conditions.UpdateConditionByType(meta.NewReadyConditionReconcilingReason(instance)) + latest.Status.Conditions.UpdateConditionByType(meta.NewHealthyConditionUnknown(instance)) if err := r.client.Status().Update(ctx, latest); err != nil { if apierrors.IsNotFound(err) { @@ -649,7 +661,7 @@ func (r *namespacedResourceController) updateReconcilingStatus(ctx context.Conte }) } -func (r *namespacedResourceController) updateStatus(ctx context.Context, instance *capsulev1beta2.TenantResource, reconcileError error) error { +func (r *namespacedResourceController) updateStatus(ctx context.Context, instance *capsulev1beta2.TenantResource, reconcileError error, healthCondition *meta.Condition) error { instance.Status.UpdateStats() return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { @@ -682,6 +694,14 @@ func (r *namespacedResourceController) updateStatus(ctx context.Context, instanc latest.Status.Conditions.UpdateConditionByType(cordonedCondition) + // Set Healthy Condition. A nil condition means health was not evaluated + // this reconcile (cordoned, gated on dependencies or deleting) -> Unknown. + if healthCondition != nil { + latest.Status.Conditions.UpdateConditionByType(*healthCondition) + } else { + latest.Status.Conditions.UpdateConditionByType(meta.NewHealthyConditionUnknown(instance)) + } + if err := r.client.Status().Update(ctx, latest); err != nil { return err } diff --git a/internal/metrics/global_tenantresource_recorder.go b/internal/metrics/global_tenantresource_recorder.go index ed105aad8..6245d3048 100644 --- a/internal/metrics/global_tenantresource_recorder.go +++ b/internal/metrics/global_tenantresource_recorder.go @@ -43,7 +43,7 @@ func (r *GlobalTenantResourceRecorder) Collectors() []prometheus.Collector { } func (r *GlobalTenantResourceRecorder) RecordConditions(resource *capsulev1beta2.GlobalTenantResource) { - for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { + for _, status := range []string{meta.ReadyCondition, meta.HealthyCondition, meta.CordonedCondition} { var value float64 cond := resource.Status.Conditions.GetConditionByType(status) diff --git a/internal/metrics/tenantresource_recorder.go b/internal/metrics/tenantresource_recorder.go index efe347641..aeb07e916 100644 --- a/internal/metrics/tenantresource_recorder.go +++ b/internal/metrics/tenantresource_recorder.go @@ -44,7 +44,7 @@ func (r *TenantResourceRecorder) Collectors() []prometheus.Collector { // RecordCondition records the condition as given for the ref. func (r *TenantResourceRecorder) RecordConditions(resource *capsulev1beta2.TenantResource) { - for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { + for _, status := range []string{meta.ReadyCondition, meta.HealthyCondition, meta.CordonedCondition} { var value float64 cond := resource.Status.Conditions.GetConditionByType(status) diff --git a/internal/webhook/route/tenantresource.go b/internal/webhook/route/tenantresource.go new file mode 100644 index 000000000..fd6cd4844 --- /dev/null +++ b/internal/webhook/route/tenantresource.go @@ -0,0 +1,24 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package route + +import "github.com/projectcapsule/capsule/pkg/runtime/handlers" + +type tenantResourceValidation struct { + handlers []handlers.Handler +} + +// TenantResourceValidation validates TenantResource and GlobalTenantResource +// objects (both carry spec.healthChecks) under a single path. +func TenantResourceValidation(handler ...handlers.Handler) handlers.Webhook { + return &tenantResourceValidation{handlers: handler} +} + +func (w *tenantResourceValidation) GetHandlers() []handlers.Handler { + return w.handlers +} + +func (w *tenantResourceValidation) GetPath() string { + return "/tenantresources/validating" +} diff --git a/internal/webhook/tenantresource/validating.go b/internal/webhook/tenantresource/validating.go new file mode 100644 index 000000000..bc428c6eb --- /dev/null +++ b/internal/webhook/tenantresource/validating.go @@ -0,0 +1,93 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantresource + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" + "github.com/projectcapsule/capsule/pkg/runtime/health" +) + +// healthChecksValidationHandler rejects TenantResource/GlobalTenantResource objects +// whose spec.healthChecks contain invalid CEL expressions, so problems surface at +// admission rather than only at reconcile time. +type healthChecksValidationHandler struct{} + +// HealthChecksValidationHandler validates the healthChecks of both the namespaced +// TenantResource and the cluster-scoped GlobalTenantResource. +func HealthChecksValidationHandler() handlers.Handler { + return &healthChecksValidationHandler{} +} + +func (h *healthChecksValidationHandler) OnCreate( + _ client.Client, + _ client.Reader, + decoder admission.Decoder, + _ events.EventRecorder, +) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + return h.validate(req, decoder) + } +} + +func (h *healthChecksValidationHandler) OnUpdate( + _ client.Client, + _ client.Reader, + decoder admission.Decoder, + _ events.EventRecorder, +) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + return h.validate(req, decoder) + } +} + +func (h *healthChecksValidationHandler) OnDelete( + client.Client, + client.Reader, + admission.Decoder, + events.EventRecorder, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *healthChecksValidationHandler) validate(req admission.Request, decoder admission.Decoder) *admission.Response { + var checks []capsulev1beta2.HealthCheckSpec + + switch req.Kind.Kind { + case "GlobalTenantResource": + obj := &capsulev1beta2.GlobalTenantResource{} + if err := decoder.Decode(req, obj); err != nil { + return ad.ErroredResponse(fmt.Errorf("failed to decode object: %w", err)) + } + + checks = obj.Spec.HealthChecks + default: + obj := &capsulev1beta2.TenantResource{} + if err := decoder.Decode(req, obj); err != nil { + return ad.ErroredResponse(fmt.Errorf("failed to decode object: %w", err)) + } + + checks = obj.Spec.HealthChecks + } + + if len(checks) == 0 { + return nil + } + + if err := health.Validate(checks); err != nil { + return ad.Denyf("invalid spec.healthChecks: %v", err) + } + + return nil +} diff --git a/pkg/api/meta/conditions.go b/pkg/api/meta/conditions.go index de15f7a79..5d0ca0b03 100644 --- a/pkg/api/meta/conditions.go +++ b/pkg/api/meta/conditions.go @@ -10,7 +10,10 @@ import ( const ( // ReadyCondition indicates the resource is ready and fully reconciled. - ReadyCondition string = "Ready" + ReadyCondition string = "Ready" + // HealthyCondition reflects whether the objects produced by the resource are + // actually healthy (as opposed to merely being applied, which Ready tracks). + HealthyCondition string = "Healthy" CordonedCondition string = "Cordoned" TerminatingCondition string = "Terminating" NotReadyCondition string = "NotReady" @@ -26,6 +29,7 @@ const ( CordonedReason string = "Cordoned" TerminatingReason string = "Terminating" ReconcilingReason string = "Reconciling" + ProgressingReason string = "Progressing" PoolExhaustedReason string = "PoolExhausted" QueueExhaustedReason string = "QueueExhausted" NamespaceExhaustedReason string = "NamespaceExhausted" @@ -114,6 +118,32 @@ func NewReadyCondition(obj client.Object) Condition { } } +// NewHealthyCondition returns a Healthy condition set to True/Succeeded. Callers +// override Status/Reason/Message when objects are unhealthy or still progressing. +func NewHealthyCondition(obj client.Object) Condition { + return Condition{ + Type: HealthyCondition, + Status: metav1.ConditionTrue, + Reason: SucceededReason, + Message: "all replicated objects are healthy", + ObservedGeneration: obj.GetGeneration(), + LastTransitionTime: metav1.Now(), + } +} + +// NewHealthyConditionUnknown returns a Healthy condition set to Unknown/Reconciling, +// used while the resource is reconciling, cordoned or being deleted. +func NewHealthyConditionUnknown(obj client.Object) Condition { + return Condition{ + Type: HealthyCondition, + Status: metav1.ConditionUnknown, + Reason: ReconcilingReason, + Message: "processing", + ObservedGeneration: obj.GetGeneration(), + LastTransitionTime: metav1.Now(), + } +} + func NewCordonedCondition(obj client.Object) Condition { return Condition{ Type: CordonedCondition, diff --git a/pkg/api/meta/reference.go b/pkg/api/meta/reference.go index c975eeab6..ddf1a6167 100644 --- a/pkg/api/meta/reference.go +++ b/pkg/api/meta/reference.go @@ -186,4 +186,16 @@ type ObjectReferenceStatusCondition struct { // Indicates wether the resource was created or adopted Created bool `json:"created,omitempty"` + + // Healthy reports the health of this individual replicated object, as evaluated + // via the resource's healthChecks (CEL) or the kstatus fallback. One of True, + // False, Unknown. Unset when no health evaluation has run yet. + // +optional + // +kubebuilder:validation:Enum=True;False;Unknown + Healthy metav1.ConditionStatus `json:"healthy,omitempty"` + + // HealthMessage is a human readable message with details about the health of + // this individual replicated object. + // +optional + HealthMessage string `json:"healthMessage,omitempty"` } diff --git a/pkg/runtime/health/health.go b/pkg/runtime/health/health.go new file mode 100644 index 000000000..e7628e504 --- /dev/null +++ b/pkg/runtime/health/health.go @@ -0,0 +1,212 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package health evaluates the health of replicated objects, either via custom +// CEL expressions (mirroring Flux healthCheckExprs semantics) or, as a default, +// via kstatus. It is self-contained so that multiple controllers can reuse it. +package health + +import ( + "fmt" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/google/cel-go/cel" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +// Status is the health classification of a single replicated object. +type Status string + +const ( + // StatusHealthy indicates the object has reached its desired state. + StatusHealthy Status = "Healthy" + // StatusUnhealthy indicates the object has (permanently) failed. + StatusUnhealthy Status = "Unhealthy" + // StatusProgressing indicates the object is still reconciling, or its health + // cannot yet be determined. + StatusProgressing Status = "Progressing" +) + +// Result is the outcome of evaluating a single object. +type Result struct { + Status Status + Message string +} + +// objectVariables are the top-level object fields exposed to CEL expressions, +// mirroring the Flux healthCheckExprs convention where fields are referenced +// directly (e.g. `status.conditions`). Referencing a field outside this set +// results in a compile error, surfaced by NewChecker and the webhook. +// +// Note: "type" is intentionally omitted because it collides with the CEL +// built-in `type` identifier; the rare Secret.type field is not exposed. +var objectVariables = []string{ + "apiVersion", "kind", "metadata", "spec", "status", + "data", "stringData", "binaryData", "immutable", +} + +// programs holds the compiled CEL programs for a single GVK. +type programs struct { + current cel.Program + failed cel.Program + inProgress cel.Program +} + +// Checker evaluates objects against a set of health checks. It is built once per +// reconcile via NewChecker and is safe for sequential reuse across objects. +type Checker struct { + programs map[schema.GroupVersionKind]programs +} + +// NewChecker compiles the CEL expressions of the given health checks. It returns +// an error if any apiVersion or expression is invalid, so problems surface once +// per reconcile rather than once per object. +func NewChecker(checks []capsulev1beta2.HealthCheckSpec) (*Checker, error) { + env, err := newEnv() + if err != nil { + return nil, err + } + + compiled := make(map[schema.GroupVersionKind]programs, len(checks)) + + for _, hc := range checks { + gv, err := schema.ParseGroupVersion(hc.APIVersion) + if err != nil { + return nil, fmt.Errorf("invalid apiVersion %q: %w", hc.APIVersion, err) + } + + gvk := gv.WithKind(hc.Kind) + + current, err := compile(env, hc.Current) + if err != nil { + return nil, fmt.Errorf("%s/%s current expression: %w", hc.APIVersion, hc.Kind, err) + } + + failed, err := compile(env, hc.Failed) + if err != nil { + return nil, fmt.Errorf("%s/%s failed expression: %w", hc.APIVersion, hc.Kind, err) + } + + inProgress, err := compile(env, hc.InProgress) + if err != nil { + return nil, fmt.Errorf("%s/%s inProgress expression: %w", hc.APIVersion, hc.Kind, err) + } + + compiled[gvk] = programs{current: current, failed: failed, inProgress: inProgress} + } + + return &Checker{programs: compiled}, nil +} + +// Validate compiles the given health checks and additionally ensures every entry +// declares at least one of current/failed. It is intended for admission-time +// validation and reuses the exact CEL environment used at runtime. +func Validate(checks []capsulev1beta2.HealthCheckSpec) error { + for i, hc := range checks { + if hc.Current == "" && hc.Failed == "" { + return fmt.Errorf("healthChecks[%d] (%s/%s): at least one of current or failed must be set", i, hc.APIVersion, hc.Kind) + } + } + + _, err := NewChecker(checks) + + return err +} + +// Check evaluates a single live object. When a health check matches the object's +// GVK, the CEL expressions decide the outcome (failed -> unhealthy, else +// inProgress -> progressing, else current -> healthy, else progressing). +// Otherwise kstatus is used as the default. +func (c *Checker) Check(obj *unstructured.Unstructured) Result { + if prg, ok := c.programs[obj.GroupVersionKind()]; ok { + vars := obj.Object + + if evalBool(prg.failed, vars) { + return Result{Status: StatusUnhealthy, Message: "failed health expression matched"} + } + + // inProgress is evaluated before current so that an object which is still + // settling is not prematurely reported healthy, even if current also matches. + if evalBool(prg.inProgress, vars) { + return Result{Status: StatusProgressing, Message: "inProgress health expression matched"} + } + + if evalBool(prg.current, vars) { + return Result{Status: StatusHealthy, Message: "current health expression matched"} + } + + return Result{Status: StatusProgressing, Message: "waiting for current health expression to match"} + } + + return kstatusResult(obj) +} + +// kstatusResult classifies an object using kstatus, which understands the +// built-in workloads (Deployments, Jobs, ...) and the Ready-condition convention +// used by most custom resources. +func kstatusResult(obj *unstructured.Unstructured) Result { + res, err := status.Compute(obj) + if err != nil { + return Result{Status: StatusProgressing, Message: err.Error()} + } + + //nolint:exhaustive + switch res.Status { + case status.CurrentStatus: + return Result{Status: StatusHealthy, Message: res.Message} + case status.FailedStatus: + return Result{Status: StatusUnhealthy, Message: res.Message} + default: + return Result{Status: StatusProgressing, Message: res.Message} + } +} + +// newEnv builds the CEL environment exposing the object's top-level fields. +func newEnv() (*cel.Env, error) { + opts := make([]cel.EnvOption, 0, len(objectVariables)) + for _, v := range objectVariables { + opts = append(opts, cel.Variable(v, cel.DynType)) + } + + return cel.NewEnv(opts...) +} + +// compile compiles a single expression, returning a nil program for the empty +// expression. It rejects expressions that cannot evaluate to a boolean. +func compile(env *cel.Env, expr string) (cel.Program, error) { + if expr == "" { + return nil, nil + } + + ast, iss := env.Compile(expr) + if iss != nil && iss.Err() != nil { + return nil, iss.Err() + } + + if out := ast.OutputType(); !out.IsExactType(cel.BoolType) && !out.IsExactType(cel.DynType) { + return nil, fmt.Errorf("expression must evaluate to bool, got %s", out.String()) + } + + return env.Program(ast) +} + +// evalBool evaluates a program against the object variables, treating a nil +// program, an evaluation error (e.g. a reference to an absent field), or a +// non-boolean result as "did not match". +func evalBool(prg cel.Program, vars map[string]any) bool { + if prg == nil { + return false + } + + out, _, err := prg.Eval(vars) + if err != nil { + return false + } + + b, ok := out.Value().(bool) + + return ok && b +} diff --git a/pkg/runtime/health/health_test.go b/pkg/runtime/health/health_test.go new file mode 100644 index 000000000..41741b44d --- /dev/null +++ b/pkg/runtime/health/health_test.go @@ -0,0 +1,239 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package health_test + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/runtime/health" +) + +const ( + syncedCurrent = "status.conditions.filter(e, e.type == 'Synced').all(e, e.status == 'True')" + syncedFailed = "status.conditions.filter(e, e.type == 'Synced').exists(e, e.status == 'False')" +) + +func obj(apiVersion, kind string, content map[string]interface{}) *unstructured.Unstructured { + u := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{"name": "test", "namespace": "default"}, + }} + for k, v := range content { + u.Object[k] = v + } + + return u +} + +func condition(condType, condStatus string) map[string]interface{} { + return map[string]interface{}{"type": condType, "status": condStatus} +} + +func TestChecker_CEL(t *testing.T) { + checks := []capsulev1beta2.HealthCheckSpec{{ + APIVersion: "example.com/v1", + Kind: "Foo", + Current: syncedCurrent, + Failed: syncedFailed, + }} + + cases := []struct { + name string + obj *unstructured.Unstructured + want health.Status + }{ + { + name: "current matches -> healthy", + obj: obj("example.com/v1", "Foo", map[string]interface{}{ + "status": map[string]interface{}{"conditions": []interface{}{condition("Synced", "True")}}, + }), + want: health.StatusHealthy, + }, + { + name: "failed matches -> unhealthy", + obj: obj("example.com/v1", "Foo", map[string]interface{}{ + "status": map[string]interface{}{"conditions": []interface{}{condition("Synced", "False")}}, + }), + want: health.StatusUnhealthy, + }, + { + name: "neither matches -> progressing", + obj: obj("example.com/v1", "Foo", map[string]interface{}{ + "status": map[string]interface{}{"conditions": []interface{}{condition("Synced", "Unknown")}}, + }), + want: health.StatusProgressing, + }, + { + name: "absent status must not panic -> progressing", + obj: obj("example.com/v1", "Foo", nil), + want: health.StatusProgressing, + }, + } + + checker, err := health.NewChecker(checks) + if err != nil { + t.Fatalf("NewChecker: %v", err) + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := checker.Check(tc.obj).Status; got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestChecker_InProgress(t *testing.T) { + // inProgress is evaluated before current: an object matching both must be + // reported progressing, not healthy. + checks := []capsulev1beta2.HealthCheckSpec{{ + APIVersion: "example.com/v1", + Kind: "Foo", + Current: "data['ready'] == 'true'", + InProgress: "data['settling'] == 'true'", + }} + + checker, err := health.NewChecker(checks) + if err != nil { + t.Fatalf("NewChecker: %v", err) + } + + cases := []struct { + name string + data map[string]interface{} + want health.Status + }{ + { + name: "current only -> healthy", + data: map[string]interface{}{"ready": "true", "settling": "false"}, + want: health.StatusHealthy, + }, + { + name: "current and inProgress overlap -> progressing", + data: map[string]interface{}{"ready": "true", "settling": "true"}, + want: health.StatusProgressing, + }, + { + name: "inProgress only -> progressing", + data: map[string]interface{}{"ready": "false", "settling": "true"}, + want: health.StatusProgressing, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := obj("example.com/v1", "Foo", map[string]interface{}{"data": tc.data}) + if got := checker.Check(o).Status; got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestChecker_KstatusFallback(t *testing.T) { + // No health check entry -> kstatus is used. + checker, err := health.NewChecker(nil) + if err != nil { + t.Fatalf("NewChecker: %v", err) + } + + ready := obj("apps/v1", "Deployment", map[string]interface{}{ + "metadata": map[string]interface{}{"name": "d", "namespace": "default", "generation": int64(1)}, + "spec": map[string]interface{}{"replicas": int64(1)}, + "status": map[string]interface{}{ + "observedGeneration": int64(1), + "replicas": int64(1), + "updatedReplicas": int64(1), + "readyReplicas": int64(1), + "availableReplicas": int64(1), + "conditions": []interface{}{ + map[string]interface{}{"type": "Available", "status": "True"}, + }, + }, + }) + if got := checker.Check(ready).Status; got != health.StatusHealthy { + t.Fatalf("ready deployment: got %q, want Healthy", got) + } + + unready := obj("apps/v1", "Deployment", map[string]interface{}{ + "metadata": map[string]interface{}{"name": "d", "namespace": "default", "generation": int64(2)}, + "spec": map[string]interface{}{"replicas": int64(2)}, + "status": map[string]interface{}{ + "observedGeneration": int64(1), + "replicas": int64(1), + }, + }) + if got := checker.Check(unready).Status; got != health.StatusProgressing { + t.Fatalf("unready deployment: got %q, want Progressing", got) + } +} + +func TestChecker_CRReadyConvention(t *testing.T) { + // A CR exposing a Ready condition is handled by the kstatus fallback. + checker, err := health.NewChecker(nil) + if err != nil { + t.Fatalf("NewChecker: %v", err) + } + + ready := obj("example.com/v1", "Widget", map[string]interface{}{ + "metadata": map[string]interface{}{"name": "w", "namespace": "default", "generation": int64(1)}, + "status": map[string]interface{}{ + "observedGeneration": int64(1), + "conditions": []interface{}{ + map[string]interface{}{"type": "Ready", "status": "True"}, + }, + }, + }) + if got := checker.Check(ready).Status; got != health.StatusHealthy { + t.Fatalf("ready CR: got %q, want Healthy", got) + } +} + +func TestNewChecker_Errors(t *testing.T) { + cases := []struct { + name string + check capsulev1beta2.HealthCheckSpec + }{ + { + name: "syntax error", + check: capsulev1beta2.HealthCheckSpec{APIVersion: "v1", Kind: "ConfigMap", Current: "status.conditions["}, + }, + { + name: "non-bool expression", + check: capsulev1beta2.HealthCheckSpec{APIVersion: "v1", Kind: "ConfigMap", Current: "1 + 1"}, + }, + { + name: "unknown top-level field", + check: capsulev1beta2.HealthCheckSpec{APIVersion: "v1", Kind: "ConfigMap", Current: "bogus.foo == true"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := health.NewChecker([]capsulev1beta2.HealthCheckSpec{tc.check}); err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} + +func TestValidate(t *testing.T) { + // Entry with neither current nor failed is rejected. + err := health.Validate([]capsulev1beta2.HealthCheckSpec{{APIVersion: "v1", Kind: "ConfigMap"}}) + if err == nil { + t.Fatalf("expected error for entry without current/failed") + } + + // Valid entry passes. + if err := health.Validate([]capsulev1beta2.HealthCheckSpec{{ + APIVersion: "example.com/v1", Kind: "Foo", Current: syncedCurrent, + }}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +}