From 1c773c24ad85c271e26fb3f0c562b8834b338650 Mon Sep 17 00:00:00 2001 From: kacerr Date: Fri, 11 Sep 2026 18:30:31 +0000 Subject: [PATCH] Add the cluster-scoped ProviderHealth CRD One operator-owned object per configured provider will carry the periodic check result: a Ready condition, per-target verdicts with their own transition times, and a ready/total count for kubectl get. There is no spec; only a viewer role is shipped since the operator is the sole writer. Written by hand (kubebuilder is not installed), registered in the existing AddKnownTypes call rather than a second init(). Co-Authored-By: Claude Opus 5 (1M context) --- PROJECT | 7 + api/v1alpha1/providerhealth_types.go | 127 ++++++++++++ api/v1alpha1/proxy_types.go | 2 +- api/v1alpha1/zz_generated.deepcopy.go | 114 +++++++++++ .../crawl.example.com_providerhealths.yaml | 193 ++++++++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/kustomization.yaml | 3 + config/rbac/providerhealth_viewer_role.yaml | 29 +++ .../2026-09-11-1526-provider-health-checks.md | 60 ++++++ 9 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 api/v1alpha1/providerhealth_types.go create mode 100644 config/crd/bases/crawl.example.com_providerhealths.yaml create mode 100644 config/rbac/providerhealth_viewer_role.yaml create mode 100644 docs/plans-executions/2026-09-11-1526-provider-health-checks.md diff --git a/PROJECT b/PROJECT index 721151d..e176257 100644 --- a/PROJECT +++ b/PROJECT @@ -18,4 +18,11 @@ resources: kind: Proxy path: gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + domain: example.com + group: crawl + kind: ProviderHealth + path: gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/providerhealth_types.go b/api/v1alpha1/providerhealth_types.go new file mode 100644 index 0000000..329463f --- /dev/null +++ b/api/v1alpha1/providerhealth_types.go @@ -0,0 +1,127 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ConditionReady summarizes a ProviderHealth's targets: True when every +// target passed its last check. +const ConditionReady = "Ready" + +// ProviderTarget is the latest check verdict for one thing a provider +// depends on: a KVM hypervisor, the Kubernetes API server, a GCP project. +type ProviderTarget struct { + // name identifies the target within its provider: the KVM host name, + // "apiserver", or the GCP project. + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` + + // address is where the check connected to. + // +optional + Address string `json:"address,omitempty"` + + // ready is true when the last check succeeded. + // +required + Ready bool `json:"ready"` + + // reason is a CamelCase verdict such as Reachable, Unreachable, + // Forbidden, SSHHandshakeFailed or PoolNotActive. + // +optional + Reason string `json:"reason,omitempty"` + + // message is human-readable detail, usually the error of a failed check. + // +kubebuilder:validation:MaxLength=1024 + // +optional + Message string `json:"message,omitempty"` + + // latencyMillis is how long the last check took. + // +optional + LatencyMillis int64 `json:"latencyMillis,omitempty"` + + // lastTransitionTime is when ready last changed. + // +required + LastTransitionTime metav1.Time `json:"lastTransitionTime"` + + // details are provider-specific facts from the last check, e.g. the + // libvirt version and free storage-pool space of a KVM host. + // +optional + Details map[string]string `json:"details,omitempty"` +} + +// ProviderHealthStatus is the result of the latest check of one provider. +type ProviderHealthStatus struct { + // type is the provider's type from the providers config: kubernetes, + // gcp or kvm. + // +optional + Type string `json:"type,omitempty"` + + // conditions hold Ready, which summarizes the targets. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // targets hold one verdict per checked target. + // +listType=map + // +listMapKey=name + // +optional + Targets []ProviderTarget `json:"targets,omitempty"` + + // targetsReady counts the ready targets as "ready/total". + // +optional + TargetsReady string `json:"targetsReady,omitempty"` + + // lastCheckTime is when the latest check finished. + // +optional + LastCheckTime *metav1.Time `json:"lastCheckTime,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,shortName=ph +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.status.type` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.status.targetsReady` +// +kubebuilder:printcolumn:name="Last Check",type=date,JSONPath=`.status.lastCheckTime` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// ProviderHealth reports the periodic health check of one configured +// provider. It is named after the provider, and the operator creates, +// updates and deletes it itself; there is no spec to edit. +type ProviderHealth struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // status is the result of the latest check + // +optional + Status ProviderHealthStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// ProviderHealthList contains a list of ProviderHealth +type ProviderHealthList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []ProviderHealth `json:"items"` +} diff --git a/api/v1alpha1/proxy_types.go b/api/v1alpha1/proxy_types.go index bd17201..e7419d8 100644 --- a/api/v1alpha1/proxy_types.go +++ b/api/v1alpha1/proxy_types.go @@ -361,7 +361,7 @@ type ProxyList struct { func init() { SchemeBuilder.Register(func(s *runtime.Scheme) error { - s.AddKnownTypes(SchemeGroupVersion, &Proxy{}, &ProxyList{}) + s.AddKnownTypes(SchemeGroupVersion, &Proxy{}, &ProxyList{}, &ProviderHealth{}, &ProviderHealthList{}) return nil }) } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index da378cb..e6d3b09 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -95,6 +95,120 @@ func (in *PlacementSpec) DeepCopy() *PlacementSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderHealth) DeepCopyInto(out *ProviderHealth) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealth. +func (in *ProviderHealth) DeepCopy() *ProviderHealth { + if in == nil { + return nil + } + out := new(ProviderHealth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderHealth) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderHealthList) DeepCopyInto(out *ProviderHealthList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ProviderHealth, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthList. +func (in *ProviderHealthList) DeepCopy() *ProviderHealthList { + if in == nil { + return nil + } + out := new(ProviderHealthList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProviderHealthList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderHealthStatus) DeepCopyInto(out *ProviderHealthStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]ProviderTarget, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastCheckTime != nil { + in, out := &in.LastCheckTime, &out.LastCheckTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthStatus. +func (in *ProviderHealthStatus) DeepCopy() *ProviderHealthStatus { + if in == nil { + return nil + } + out := new(ProviderHealthStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderTarget) DeepCopyInto(out *ProviderTarget) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) + if in.Details != nil { + in, out := &in.Details, &out.Details + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderTarget. +func (in *ProviderTarget) DeepCopy() *ProviderTarget { + if in == nil { + return nil + } + out := new(ProviderTarget) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Proxy) DeepCopyInto(out *Proxy) { *out = *in diff --git a/config/crd/bases/crawl.example.com_providerhealths.yaml b/config/crd/bases/crawl.example.com_providerhealths.yaml new file mode 100644 index 0000000..838d3f8 --- /dev/null +++ b/config/crd/bases/crawl.example.com_providerhealths.yaml @@ -0,0 +1,193 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: providerhealths.crawl.example.com +spec: + group: crawl.example.com + names: + kind: ProviderHealth + listKind: ProviderHealthList + plural: providerhealths + shortNames: + - ph + singular: providerhealth + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .status.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.targetsReady + name: Targets + type: string + - jsonPath: .status.lastCheckTime + name: Last Check + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ProviderHealth reports the periodic health check of one configured + provider. It is named after the provider, and the operator creates, + updates and deletes it itself; there is no spec to edit. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + status: + description: status is the result of the latest check + properties: + conditions: + description: conditions hold Ready, which summarizes the targets. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + lastCheckTime: + description: lastCheckTime is when the latest check finished. + format: date-time + type: string + targets: + description: targets hold one verdict per checked target. + items: + description: |- + ProviderTarget is the latest check verdict for one thing a provider + depends on: a KVM hypervisor, the Kubernetes API server, a GCP project. + properties: + address: + description: address is where the check connected to. + type: string + details: + additionalProperties: + type: string + description: |- + details are provider-specific facts from the last check, e.g. the + libvirt version and free storage-pool space of a KVM host. + type: object + lastTransitionTime: + description: lastTransitionTime is when ready last changed. + format: date-time + type: string + latencyMillis: + description: latencyMillis is how long the last check took. + format: int64 + type: integer + message: + description: message is human-readable detail, usually the error + of a failed check. + maxLength: 1024 + type: string + name: + description: |- + name identifies the target within its provider: the KVM host name, + "apiserver", or the GCP project. + minLength: 1 + type: string + ready: + description: ready is true when the last check succeeded. + type: boolean + reason: + description: |- + reason is a CamelCase verdict such as Reachable, Unreachable, + Forbidden, SSHHandshakeFailed or PoolNotActive. + type: string + required: + - lastTransitionTime + - name + - ready + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + targetsReady: + description: targetsReady counts the ready targets as "ready/total". + type: string + type: + description: |- + type is the provider's type from the providers config: kubernetes, + gcp or kvm. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 7ab3951..7204749 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,4 +3,5 @@ # It should be run by config/default resources: - bases/crawl.example.com_proxies.yaml +- bases/crawl.example.com_providerhealths.yaml # +kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 9ff20da..5a2908a 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -25,4 +25,7 @@ resources: - proxy_admin_role.yaml - proxy_editor_role.yaml - proxy_viewer_role.yaml +# ProviderHealth objects are written only by the operator, so they get a +# viewer role and nothing else. +- providerhealth_viewer_role.yaml diff --git a/config/rbac/providerhealth_viewer_role.yaml b/config/rbac/providerhealth_viewer_role.yaml new file mode 100644 index 0000000..43978ae --- /dev/null +++ b/config/rbac/providerhealth_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project egress-proxies-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to ProviderHealth objects, the results of the +# operator's periodic provider checks. The operator creates, updates and +# deletes them itself, so no admin or editor role is provided. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: egress-proxies-operator + app.kubernetes.io/managed-by: kustomize + name: providerhealth-viewer-role +rules: +- apiGroups: + - crawl.example.com + resources: + - providerhealths + verbs: + - get + - list + - watch +- apiGroups: + - crawl.example.com + resources: + - providerhealths/status + verbs: + - get diff --git a/docs/plans-executions/2026-09-11-1526-provider-health-checks.md b/docs/plans-executions/2026-09-11-1526-provider-health-checks.md new file mode 100644 index 0000000..67362fa --- /dev/null +++ b/docs/plans-executions/2026-09-11-1526-provider-health-checks.md @@ -0,0 +1,60 @@ +# Execution: provider health checks +Plan: [docs/plans/2026-09-11-1526-provider-health-checks.md](../plans/2026-09-11-1526-provider-health-checks.md) + +- [x] Step 0 — Save the plan +- [x] Step 1 — ProviderHealth API and CRD +- [ ] Step 2 — Contract plus the kubernetes and GCP checks +- [ ] Step 3 — KVM check +- [ ] Step 4 — Monitor +- [ ] Step 5 — Metrics and wiring +- [ ] Step 6 — Docs +- [ ] Step 7 — Validate, record, push, open the PR + +## Step 0 — Save the plan + +Branched off `main` (at the PR #5 merge, `eb8f075`). The plan was copied out of +`~/.claude/plans/` and committed on its own, before any code, as the repo's plan rules +require: + +```bash +git checkout -b feat/provider-health main +cp ~/.claude/plans/now-i-would-like-flickering-wall.md docs/plans/2026-09-11-1526-provider-health-checks.md +``` + +The uncommitted `.claude/settings.json` change was left unstaged and stays out of this +branch. + +## Step 1 — ProviderHealth API and CRD + +`kubebuilder` isn't installed here (only `bin/controller-gen`, `bin/kustomize` and +`bin/setup-envtest` are), so `api/v1alpha1/providerhealth_types.go` and the `PROJECT` +entry were written by hand instead of with `kubebuilder create api`. For a cluster-scoped +resource `PROJECT` simply omits `namespaced`, which is what kubebuilder itself writes. + +The new types are registered in the existing `AddKnownTypes` call in `proxy_types.go`, +not in a second `init()`. + +The deepcopy code, the CRD and the RBAC come from the usual generators: + +```bash +make generate manifests +go build ./... && go vet ./api/... && go test -short ./api/... +``` + +To confirm the new CRD and the viewer role actually reach the rendered install, I built +the kustomization and grepped for them: + +```bash +bin/kustomize build config/default | grep -n providerhealth +# providerhealths.crawl.example.com (scope: Cluster, shortName ph) +# egress-proxies-operator-providerhealth-viewer-role +``` + +Worth noting: +- The only role added is the viewer role. The operator is the object's only writer, so + the admin and editor roles kubebuilder scaffolds for Proxy were not added. +- The type file has no unit test, since it has no logic. The schema, including the + required `ready`/`lastTransitionTime` and the list-map keys, is proven by the envtest + round-trip in Step 4. +- `config/rbac/role.yaml` is unchanged so far. The manager's own permissions arrive in + Step 4 with the Monitor's RBAC markers.