From b499064b4ad7429ee1f632c3d6063eef9ada05f4 Mon Sep 17 00:00:00 2001 From: "renovate-interworks[bot]" <309859563+renovate-interworks[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:06:31 +0000 Subject: [PATCH 01/19] chore(deps): add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..efa5829 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>InterWorks/renovate-config" + ] +} From 263951f877e8e54936ccfad1e836d76e9c229979 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 10:02:17 -0400 Subject: [PATCH 02/19] chore: pin CLI tool versions with mise and add CLAUDE.md guidance mise.toml pins CLI tool versions; CLAUDE.md gives Claude Code guidance for working in this repo. --- CLAUDE.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ mise.toml | 4 +++ 2 files changed, 85 insertions(+) create mode 100644 CLAUDE.md create mode 100644 mise.toml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f0855c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +TyKO (Typesense Kubernetes Operator) — a Kubernetes operator, built with Operator SDK / kubebuilder (go.kubebuilder.io/v4), that manages the full lifecycle of highly-available Typesense clusters via a single CRD (`TypesenseCluster`, group `ts.opentelekomcloud.com/v1alpha1`). It automates ConfigMaps, Secrets, PVCs, StatefulSets, Services, Ingress/HTTPRoute, metrics scrapers, and — most notably — Raft quorum discovery/recovery without sidecars. + +## Common commands + +```bash +# Build & code hygiene +make manifests generate # regenerate CRDs/RBAC (controller-gen) and deepcopy code — run after editing api/v1alpha1 types or +kubebuilder markers +make fmt vet # go fmt / go vet +make build # manifests generate fmt vet + go build -> bin/manager +make run # run the operator against the current kubeconfig context (out-of-cluster) + +# Lint +make lint # golangci-lint run +make lint-fix # golangci-lint run --fix + +# Tests +make test # runs manifests generate fmt vet setup-envtest, then envtest-based unit/integration tests for all packages except test/ +# Run a single package's tests directly once envtest assets are set up: +KUBEBUILDER_ASSETS="$(setup-envtest use -p path)" go test ./internal/controller/... -run TestSomething -v + +make test-e2e # spins up a local Kind cluster, runs test/e2e (ginkgo), tears the cluster down + # CERT_MANAGER_INSTALL_SKIP=true to skip cert-manager install + +# CRDs / deployment (kustomize-based) +make install # install CRDs into the cluster in ~/.kube/config +make deploy # deploy the controller via config/default kustomize overlay +make deploy-with-samples # install + apply sample CRs +make undeploy / uninstall # tear down + +# Helm chart (charts/typesense-operator) is generated from kustomize output — see `make helmify` +``` + +Tests use Ginkgo/Gomega (`internal/controller/suite_test.go`, `test/e2e`), driven through envtest for controller-level tests and a real Kind cluster for e2e. + +## Architecture + +### Single controller, many reconcile phases + +There is one reconciler, `TypesenseClusterReconciler` (`internal/controller/typesensecluster_controller.go`), driving one CRD (`TypesenseCluster`). `Reconcile()` runs a fixed sequence of phases, each in its own file, each with its own idempotent update strategy (documented as a comment above the call site in `typesensecluster_controller.go`): + +1. `ReconcileSecret` (`typesensecluster_secret.go`) — admin API key Secret; **immutable**, never updated after creation. +2. `ReconcileConfigMap` (`typesensecluster_configmap.go`) — the peer-nodes ConfigMap (`ClusterNodesConfigMap`); updated in place when the node list changes, and its return value (`configMapUpdated *bool`) drives whether the rest of reconcile treats this pass as bootstrap vs. a quorum-affecting change. +3. `ReconcileServices` (`typesensecluster_services.go`) +4. `ReconcileIngress` (`typesensecluster_ingress.go`) +5. `ReconcileHttpRoute` (`typesensecluster_httproute.go`) — Gateway API HTTPRoute, alternative to Ingress +6. `ReconcileScraper` (`typesensecluster_scraper.go`) — drops and recreates on change +7. `ReconcilePodMonitor` (`typesensecluster_podmonitor.go`) — Prometheus PodMonitor/metrics exporter +8. `ReconcileStatefulSet` (`typesensecluster_statefulset.go`) — the Typesense StatefulSet itself; full spec diff/update. `typesensecluster_statefulset_hash.go` computes a hash of the pod template to detect when a rolling update is actually needed. +9. `ReconcileQuorum` (`typesensecluster_quorum.go`, helpers in `typesensecluster_quorum_helpers.go`, types in `typesensecluster_quorum_types.go`) — talks to the Typesense HTTP health/stats API on each pod to compute Raft quorum health (available vs. min-required nodes, write/read lag), restarts unscheduled pods, and derives a `ConditionQuorum` status. + +Each phase failure sets a "NotReady" status condition (`typesensecluster_condition_types.go`, `setConditionNotReady`/`setConditionReady`) and short-circuits the reconcile loop by returning early — phases are strictly sequential and later phases assume earlier ones succeeded. + +### Bootstrapping vs. reconciling, and the ConfigMap-triggered requeue dance + +After the StatefulSet phase, the controller distinguishes two actions based on whether `ReconcileConfigMap` reported a change: +- `configMapUpdated == nil`: nothing changed — either steady-state (`Reconciling`) or first-ever creation (`Bootstrapping`, short 15s requeue). +- `configMapUpdated != nil` and `true`: the peer list changed, so `forcePodsConfigMapUpdate` force-restarts pods to pick up the new mounted ConfigMap (kubelet syncs configmaps ~every 60s on its own), the condition is set to `QuorumNotReadyWaitATerm`, and reconcile requeues after `configMapRequeuePeriod` (2 min) to give kubelet time to propagate before checking quorum again. + +Only once the ConfigMap has settled does the controller call `ReconcileQuorum` and fold its `ConditionQuorum` result into the CR's status/events (`QuorumNeedsAttention*` conditions surface as Warning events requiring manual intervention — lagging writes or out-of-memory/disk; anything else not-ready is retried). + +### API types layout (`api/v1alpha1/`) + +`typesensecluster_types.go` holds the root `TypesenseClusterSpec`/`Status`; the sub-structs for each concern live in their own `typesensecluster_types_*.go` files (`_storage`, `_service`, `_ingress`, `_httproute`, `_scraper`, `_metrics`, `_healthcheck`, `_securitycontexts`), with helper methods in `typesensecluster_types_helpers.go`. `zz_generated.deepcopy.go` is generated — never hand-edit it; run `make generate` instead. + +### Config entry point + +`cmd/main.go` wires up the manager: scheme registration, leader election, health/readiness probes, metrics server, and controller setup (`SetupWithManager`). It also builds the extra clients the reconciler needs beyond the controller-runtime client (`DiscoveryClient`, `ClientSet`, `InCluster` detection) since quorum health checks and pod restarts need direct API access. + +## Making changes to the CRD + +Any change to `api/v1alpha1/typesensecluster_types*.go` (new field, changed `+kubebuilder:` marker, etc.) requires `make manifests generate` before building/testing — this regenerates CRD YAML under `config/crd/` and `zz_generated.deepcopy.go`. The Helm chart under `charts/typesense-operator` is produced from the kustomize output via `make helmify` and should be regenerated alongside CRD/manifest changes, not edited by hand. + +## golangci-lint notes + +Config in `.golangci.yml`: `api/*` is exempt from `lll` (long lines — CRD marker comments run long); `internal/*` is exempt from `dupl` and `lll`. Keep new code lint-clean under the enabled linter set (see file) rather than adding new exemptions. diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..d4cf6cc --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +kind = "0.32.0" +kubectl = "1.36.2" +helm = "4.2.4" From 3fc31d56f747307aafb77e795df33ef4dbef3df0 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 10:02:41 -0400 Subject: [PATCH 03/19] feat: add TypesenseApiKey CRD and reconciler Introduces a new TypesenseApiKey CRD (group ts.opentelekomcloud.com) that manages the full lifecycle of a Typesense API key against a referenced TypesenseCluster, including a cluster in a different namespace via an optional clusterRef.namespace: - create the remote key via the Typesense keys API and mirror its value into a Secret - rotate the key (delete + recreate) whenever the CR's spec changes - periodically detect and heal drift if the remote key is deleted or edited out-of-band - delete the remote key on CR deletion, guarded by a finalizer Wires TypesenseApiKeyReconciler into cmd/main.go and regenerates the CRD manifest, RBAC role, kustomization, and deepcopy code. --- PROJECT | 9 + api/v1alpha1/typesenseapikey_types.go | 117 +++++++ api/v1alpha1/zz_generated.deepcopy.go | 181 +++++++++-- cmd/main.go | 12 + ...opentelekomcloud.com_typesenseapikeys.yaml | 193 +++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/role.yaml | 3 + internal/controller/typesenseapikey_client.go | 159 +++++++++ .../typesenseapikey_condition_types.go | 18 ++ .../controller/typesenseapikey_constants.go | 10 + .../controller/typesenseapikey_controller.go | 303 ++++++++++++++++++ .../controller/typesenseapikey_helpers.go | 83 +++++ internal/controller/typesenseapikey_secret.go | 73 +++++ 13 files changed, 1140 insertions(+), 22 deletions(-) create mode 100644 api/v1alpha1/typesenseapikey_types.go create mode 100644 config/crd/bases/ts.opentelekomcloud.com_typesenseapikeys.yaml create mode 100644 internal/controller/typesenseapikey_client.go create mode 100644 internal/controller/typesenseapikey_condition_types.go create mode 100644 internal/controller/typesenseapikey_constants.go create mode 100644 internal/controller/typesenseapikey_controller.go create mode 100644 internal/controller/typesenseapikey_helpers.go create mode 100644 internal/controller/typesenseapikey_secret.go diff --git a/PROJECT b/PROJECT index c3a18a7..268e5f1 100644 --- a/PROJECT +++ b/PROJECT @@ -20,4 +20,13 @@ resources: kind: TypesenseCluster path: github.com/akyriako/typesense-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: opentelekomcloud.com + group: ts + kind: TypesenseApiKey + path: github.com/akyriako/typesense-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/typesenseapikey_types.go b/api/v1alpha1/typesenseapikey_types.go new file mode 100644 index 0000000..def3946 --- /dev/null +++ b/api/v1alpha1/typesenseapikey_types.go @@ -0,0 +1,117 @@ +/* +Copyright 2024. + +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 ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TypesenseClusterReference identifies a TypesenseCluster, optionally in another namespace. +type TypesenseClusterReference struct { + // Name of the TypesenseCluster. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Namespace of the TypesenseCluster. Defaults to the TypesenseApiKey's own namespace. + // +optional + Namespace string `json:"namespace,omitempty"` +} + +// TypesenseApiKeySpec defines the desired state of TypesenseApiKey +type TypesenseApiKeySpec struct { + // ClusterRef is the TypesenseCluster this key is issued against. If Namespace is omitted, the + // TypesenseCluster is looked up in the TypesenseApiKey's own namespace. + // +kubebuilder:validation:Required + ClusterRef TypesenseClusterReference `json:"clusterRef"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Description string `json:"description"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Actions []string `json:"actions"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Collections []string `json:"collections"` + + // ExpiresAt maps to Typesense's expires_at (unix seconds). + // +optional + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + + // Value pins a specific key string instead of letting Typesense auto-generate one. + // +optional + Value *string `json:"value,omitempty"` +} + +// TypesenseApiKeyStatus defines the observed state of TypesenseApiKey +type TypesenseApiKeyStatus struct { + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status,xDescriptors={"urn:alm:descriptor:io.kubernetes.conditions"} + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // +optional + Phase string `json:"phase,omitempty"` + + // KeyId is the numeric id Typesense assigned to this key, needed to delete/rotate it. + // +optional + KeyId *int64 `json:"keyId,omitempty"` + + // ValuePrefix is the redacted prefix Typesense returns when fetching a key, used for audit/drift display only. + // +optional + ValuePrefix string `json:"valuePrefix,omitempty"` + + // ObservedGeneration is the .metadata.generation last successfully reconciled into a Typesense key. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // SecretRef is the name of the Secret holding the current plaintext key value. + // +optional + SecretRef corev1.LocalObjectReference `json:"secretRef,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// TypesenseApiKey is the Schema for the typesenseapikeys API +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef.name` +// +kubebuilder:printcolumn:name="Key Id",type=integer,JSONPath=`.status.keyId` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status" +type TypesenseApiKey struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TypesenseApiKeySpec `json:"spec,omitempty"` + Status TypesenseApiKeyStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// TypesenseApiKeyList contains a list of TypesenseApiKey +type TypesenseApiKeyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []TypesenseApiKey `json:"items"` +} + +func init() { + SchemeBuilder.Register(&TypesenseApiKey{}, &TypesenseApiKeyList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index e58336a..b3cc492 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,9 +21,9 @@ limitations under the License. package v1alpha1 import ( - "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" apisv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -43,7 +43,7 @@ func (in *DocSearchScraperSpec) DeepCopyInto(out *DocSearchScraperSpec) { } if in.AuthConfiguration != nil { in, out := &in.AuthConfiguration, &out.AuthConfiguration - *out = new(v1.LocalObjectReference) + *out = new(corev1.LocalObjectReference) **out = **in } } @@ -88,7 +88,7 @@ func (in *HealthCheckSpec) DeepCopyInto(out *HealthCheckSpec) { *out = *in if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = new(v1.ResourceRequirements) + *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } } @@ -204,7 +204,7 @@ func (in *IngressSpec) DeepCopyInto(out *IngressSpec) { } if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = new(v1.ResourceRequirements) + *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } if in.ReadOnlyRootFilesystem != nil { @@ -234,7 +234,7 @@ func (in *MetricsExporterSpec) DeepCopyInto(out *MetricsExporterSpec) { *out = *in if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = new(v1.ResourceRequirements) + *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } } @@ -254,19 +254,19 @@ func (in *ReadOnlyRootFilesystemSpec) DeepCopyInto(out *ReadOnlyRootFilesystemSp *out = *in if in.SecurityContext != nil { in, out := &in.SecurityContext, &out.SecurityContext - *out = new(v1.SecurityContext) + *out = new(corev1.SecurityContext) (*in).DeepCopyInto(*out) } if in.Volumes != nil { in, out := &in.Volumes, &out.Volumes - *out = make([]v1.Volume, len(*in)) + *out = make([]corev1.Volume, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.VolumeMounts != nil { in, out := &in.VolumeMounts, &out.VolumeMounts - *out = make([]v1.VolumeMount, len(*in)) + *out = make([]corev1.VolumeMount, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -288,22 +288,22 @@ func (in *SecurityContextSpec) DeepCopyInto(out *SecurityContextSpec) { *out = *in if in.PodSecurityContext != nil { in, out := &in.PodSecurityContext, &out.PodSecurityContext - *out = new(v1.PodSecurityContext) + *out = new(corev1.PodSecurityContext) (*in).DeepCopyInto(*out) } if in.TypesenseSecurityContext != nil { in, out := &in.TypesenseSecurityContext, &out.TypesenseSecurityContext - *out = new(v1.SecurityContext) + *out = new(corev1.SecurityContext) (*in).DeepCopyInto(*out) } if in.HealthcheckSecurityContext != nil { in, out := &in.HealthcheckSecurityContext, &out.HealthcheckSecurityContext - *out = new(v1.SecurityContext) + *out = new(corev1.SecurityContext) (*in).DeepCopyInto(*out) } if in.MetricsSecurityContext != nil { in, out := &in.MetricsSecurityContext, &out.MetricsSecurityContext - *out = new(v1.SecurityContext) + *out = new(corev1.SecurityContext) (*in).DeepCopyInto(*out) } } @@ -323,7 +323,7 @@ func (in *ServiceSpec) DeepCopyInto(out *ServiceSpec) { *out = *in if in.ExternalTrafficPolicy != nil { in, out := &in.ExternalTrafficPolicy, &out.ExternalTrafficPolicy - *out = new(v1.ServiceExternalTrafficPolicyType) + *out = new(corev1.ServiceExternalTrafficPolicyType) **out = **in } if in.Annotations != nil { @@ -368,6 +368,128 @@ func (in *StorageSpec) DeepCopy() *StorageSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TypesenseApiKey) DeepCopyInto(out *TypesenseApiKey) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TypesenseApiKey. +func (in *TypesenseApiKey) DeepCopy() *TypesenseApiKey { + if in == nil { + return nil + } + out := new(TypesenseApiKey) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TypesenseApiKey) 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 *TypesenseApiKeyList) DeepCopyInto(out *TypesenseApiKeyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TypesenseApiKey, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TypesenseApiKeyList. +func (in *TypesenseApiKeyList) DeepCopy() *TypesenseApiKeyList { + if in == nil { + return nil + } + out := new(TypesenseApiKeyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TypesenseApiKeyList) 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 *TypesenseApiKeySpec) DeepCopyInto(out *TypesenseApiKeySpec) { + *out = *in + out.ClusterRef = in.ClusterRef + if in.Actions != nil { + in, out := &in.Actions, &out.Actions + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Collections != nil { + in, out := &in.Collections, &out.Collections + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExpiresAt != nil { + in, out := &in.ExpiresAt, &out.ExpiresAt + *out = (*in).DeepCopy() + } + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TypesenseApiKeySpec. +func (in *TypesenseApiKeySpec) DeepCopy() *TypesenseApiKeySpec { + if in == nil { + return nil + } + out := new(TypesenseApiKeySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TypesenseApiKeyStatus) DeepCopyInto(out *TypesenseApiKeyStatus) { + *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.KeyId != nil { + in, out := &in.KeyId, &out.KeyId + *out = new(int64) + **out = **in + } + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TypesenseApiKeyStatus. +func (in *TypesenseApiKeyStatus) DeepCopy() *TypesenseApiKeyStatus { + if in == nil { + return nil + } + out := new(TypesenseApiKeyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TypesenseCluster) DeepCopyInto(out *TypesenseCluster) { *out = *in @@ -427,17 +549,32 @@ func (in *TypesenseClusterList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TypesenseClusterReference) DeepCopyInto(out *TypesenseClusterReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TypesenseClusterReference. +func (in *TypesenseClusterReference) DeepCopy() *TypesenseClusterReference { + if in == nil { + return nil + } + out := new(TypesenseClusterReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TypesenseClusterSpec) DeepCopyInto(out *TypesenseClusterSpec) { *out = *in if in.ImagePullSecrets != nil { in, out := &in.ImagePullSecrets, &out.ImagePullSecrets - *out = make([]v1.LocalObjectReference, len(*in)) + *out = make([]corev1.LocalObjectReference, len(*in)) copy(*out, *in) } if in.AdminApiKey != nil { in, out := &in.AdminApiKey, &out.AdminApiKey - *out = new(v1.SecretReference) + *out = new(corev1.SecretReference) **out = **in } if in.CorsDomains != nil { @@ -447,12 +584,12 @@ func (in *TypesenseClusterSpec) DeepCopyInto(out *TypesenseClusterSpec) { } if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = new(v1.ResourceRequirements) + *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } if in.Affinity != nil { in, out := &in.Affinity, &out.Affinity - *out = new(v1.Affinity) + *out = new(corev1.Affinity) (*in).DeepCopyInto(*out) } if in.NodeSelector != nil { @@ -464,14 +601,14 @@ func (in *TypesenseClusterSpec) DeepCopyInto(out *TypesenseClusterSpec) { } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations - *out = make([]v1.Toleration, len(*in)) + *out = make([]corev1.Toleration, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.AdditionalServerConfiguration != nil { in, out := &in.AdditionalServerConfiguration, &out.AdditionalServerConfiguration - *out = new(v1.LocalObjectReference) + *out = new(corev1.LocalObjectReference) **out = **in } if in.ServiceAnnotations != nil { @@ -536,7 +673,7 @@ func (in *TypesenseClusterSpec) DeepCopyInto(out *TypesenseClusterSpec) { } if in.TopologySpreadConstraints != nil { in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints - *out = make([]v1.TopologySpreadConstraint, len(*in)) + *out = make([]corev1.TopologySpreadConstraint, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -573,7 +710,7 @@ func (in *TypesenseClusterStatus) DeepCopyInto(out *TypesenseClusterStatus) { *out = *in if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions - *out = make([]metav1.Condition, len(*in)) + *out = make([]v1.Condition, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/cmd/main.go b/cmd/main.go index 5350096..64cadb3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,8 +19,10 @@ package main import ( "crypto/tls" "flag" + "net/http" "os" "path/filepath" + "time" "go.uber.org/zap/zapcore" "k8s.io/client-go/kubernetes" @@ -247,6 +249,16 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "TypesenseCluster") os.Exit(1) } + + if err = (&controller.TypesenseApiKeyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("typesenseapikey-controller"), + HttpClient: &http.Client{Timeout: 10 * time.Second}, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "TypesenseApiKey") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/ts.opentelekomcloud.com_typesenseapikeys.yaml b/config/crd/bases/ts.opentelekomcloud.com_typesenseapikeys.yaml new file mode 100644 index 0000000..0b0ab5f --- /dev/null +++ b/config/crd/bases/ts.opentelekomcloud.com_typesenseapikeys.yaml @@ -0,0 +1,193 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: typesenseapikeys.ts.opentelekomcloud.com +spec: + group: ts.opentelekomcloud.com + names: + kind: TypesenseApiKey + listKind: TypesenseApiKeyList + plural: typesenseapikeys + singular: typesenseapikey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .status.keyId + name: Key Id + type: integer + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: TypesenseApiKey is the Schema for the typesenseapikeys API + 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 + spec: + description: TypesenseApiKeySpec defines the desired state of TypesenseApiKey + properties: + actions: + items: + type: string + minItems: 1 + type: array + clusterRef: + description: |- + ClusterRef is the TypesenseCluster this key is issued against. If Namespace is omitted, the + TypesenseCluster is looked up in the TypesenseApiKey's own namespace. + properties: + name: + description: Name of the TypesenseCluster. + minLength: 1 + type: string + namespace: + description: Namespace of the TypesenseCluster. Defaults to the + TypesenseApiKey's own namespace. + type: string + required: + - name + type: object + collections: + items: + type: string + minItems: 1 + type: array + description: + minLength: 1 + type: string + expiresAt: + description: ExpiresAt maps to Typesense's expires_at (unix seconds). + format: date-time + type: string + value: + description: Value pins a specific key string instead of letting Typesense + auto-generate one. + type: string + required: + - actions + - clusterRef + - collections + - description + type: object + status: + description: TypesenseApiKeyStatus defines the observed state of TypesenseApiKey + properties: + conditions: + 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 + keyId: + description: KeyId is the numeric id Typesense assigned to this key, + needed to delete/rotate it. + format: int64 + type: integer + observedGeneration: + description: ObservedGeneration is the .metadata.generation last successfully + reconciled into a Typesense key. + format: int64 + type: integer + phase: + type: string + secretRef: + description: SecretRef is the name of the Secret holding the current + plaintext key value. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + valuePrefix: + description: ValuePrefix is the redacted prefix Typesense returns + when fetching a key, used for audit/drift display only. + 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 bcf6d1b..840f6d4 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/ts.opentelekomcloud.com_typesenseclusters.yaml +- bases/ts.opentelekomcloud.com_typesenseapikeys.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 8204200..585710a 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -124,6 +124,7 @@ rules: - apiGroups: - ts.opentelekomcloud.com resources: + - typesenseapikeys - typesenseclusters verbs: - create @@ -136,12 +137,14 @@ rules: - apiGroups: - ts.opentelekomcloud.com resources: + - typesenseapikeys/finalizers - typesenseclusters/finalizers verbs: - update - apiGroups: - ts.opentelekomcloud.com resources: + - typesenseapikeys/status - typesenseclusters/status verbs: - get diff --git a/internal/controller/typesenseapikey_client.go b/internal/controller/typesenseapikey_client.go new file mode 100644 index 0000000..1330233 --- /dev/null +++ b/internal/controller/typesenseapikey_client.go @@ -0,0 +1,159 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" +) + +// CreateKeyRequest is the request body for POST /keys. +type CreateKeyRequest struct { + Description string `json:"description"` + Actions []string `json:"actions"` + Collections []string `json:"collections"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + Value *string `json:"value,omitempty"` +} + +// KeyResponse is the response body Typesense returns for POST /keys and GET /keys/{id}. +// Value is only ever populated on the POST /keys response - Typesense never returns it again afterwards. +type KeyResponse struct { + Id int64 `json:"id"` + Description string `json:"description"` + Actions []string `json:"actions"` + Collections []string `json:"collections"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Value string `json:"value,omitempty"` + ValuePrefix string `json:"value_prefix,omitempty"` +} + +// deleteKeyResponse is the response body Typesense returns for DELETE /keys/{id}. +type deleteKeyResponse struct { + Id int64 `json:"id"` +} + +// typesenseApiError is the response body Typesense returns for non-2xx responses. +type typesenseApiError struct { + Message string `json:"message"` +} + +func (r *TypesenseApiKeyReconciler) createKey(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, adminKey []byte, req CreateKeyRequest) (*KeyResponse, error) { + u, err := r.buildKeysUrl(ts, TypesenseKeysPath) + if err != nil { + return nil, err + } + + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + var keyResponse KeyResponse + if err := r.doKeysRequest(ctx, http.MethodPost, u, adminKey, body, &keyResponse); err != nil { + return nil, err + } + + return &keyResponse, nil +} + +func (r *TypesenseApiKeyReconciler) getKey(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, adminKey []byte, id int64) (*KeyResponse, error) { + u, err := r.buildKeysUrl(ts, fmt.Sprintf("%s/%d", TypesenseKeysPath, id)) + if err != nil { + return nil, err + } + + var keyResponse KeyResponse + if err := r.doKeysRequest(ctx, http.MethodGet, u, adminKey, nil, &keyResponse); err != nil { + return nil, err + } + + return &keyResponse, nil +} + +// deleteKey deletes a key by id. A 404 from Typesense (key already gone) is treated as success. +func (r *TypesenseApiKeyReconciler) deleteKey(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, adminKey []byte, id int64) error { + u, err := r.buildKeysUrl(ts, fmt.Sprintf("%s/%d", TypesenseKeysPath, id)) + if err != nil { + return err + } + + var resp deleteKeyResponse + err = r.doKeysRequest(ctx, http.MethodDelete, u, adminKey, nil, &resp) + if err != nil && isNotFoundErr(err) { + return nil + } + + return err +} + +func (r *TypesenseApiKeyReconciler) doKeysRequest(ctx context.Context, method string, u string, adminKey []byte, body []byte, out any) error { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) + if err != nil { + return err + } + + req.Header.Set("x-typesense-api-key", string(adminKey)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := r.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var apiErr typesenseApiError + _ = json.Unmarshal(respBody, &apiErr) + return &keysApiError{statusCode: resp.StatusCode, message: apiErr.Message, body: string(respBody)} + } + + if len(respBody) == 0 { + return nil + } + + return json.Unmarshal(respBody, out) +} + +// buildKeysUrl targets the cluster's ClusterIP REST Service, since key CRUD is cluster-wide +// state and does not need to go through any specific pod. Only supports in-cluster manager +// execution today - see plan notes on out-of-cluster dev mode. +func (r *TypesenseApiKeyReconciler) buildKeysUrl(ts *tsv1alpha1.TypesenseCluster, path string) (string, error) { + svc := fmt.Sprintf("%s.%s.svc.cluster.local", fmt.Sprintf(ClusterRestService, ts.Name), ts.Namespace) + return url.JoinPath(fmt.Sprintf("http://%s:%d", svc, ts.Spec.ApiPort), path) +} + +type keysApiError struct { + statusCode int + message string + body string +} + +func (e *keysApiError) Error() string { + if e.message != "" { + return fmt.Sprintf("typesense keys api returned %d: %s", e.statusCode, e.message) + } + return fmt.Sprintf("typesense keys api returned %d: %s", e.statusCode, e.body) +} + +func isNotFoundErr(err error) bool { + apiErr, ok := err.(*keysApiError) + return ok && apiErr.statusCode == http.StatusNotFound +} diff --git a/internal/controller/typesenseapikey_condition_types.go b/internal/controller/typesenseapikey_condition_types.go new file mode 100644 index 0000000..81a631e --- /dev/null +++ b/internal/controller/typesenseapikey_condition_types.go @@ -0,0 +1,18 @@ +package controller + +// Definitions to manage TypesenseApiKey status conditions. +const ( + ApiKeyConditionTypeReady = "Ready" + + ApiKeyConditionReasonReconciliationInProgress = "ReconciliationInProgress" + ApiKeyConditionReasonClusterNotFound = "ClusterNotFound" + ApiKeyConditionReasonAdminKeySecretNotReady = "AdminKeySecretNotReady" + ApiKeyConditionReasonKeyCreateFailed = "KeyCreateFailed" + ApiKeyConditionReasonKeyRotateFailed = "KeyRotateFailed" + ApiKeyConditionReasonKeyDriftCheckFailed = "KeyDriftCheckFailed" + ApiKeyConditionReasonSecretNotReady = "SecretNotReady" + ApiKeyConditionReasonReady = "Ready" + + ApiKeyInitReconciliationMessage = "Starting reconciliation" + ApiKeyUpdateStatusMessageFailed = "failed to update typesense api key status" +) diff --git a/internal/controller/typesenseapikey_constants.go b/internal/controller/typesenseapikey_constants.go new file mode 100644 index 0000000..6e13cec --- /dev/null +++ b/internal/controller/typesenseapikey_constants.go @@ -0,0 +1,10 @@ +package controller + +const ( + ApiKeySecretName = "%s-typesense-key" + ApiKeySecretKeyName = "value" + + ApiKeyFinalizer = "typesenseapikey.ts.opentelekomcloud.com/finalizer" + + TypesenseKeysPath = "/keys" +) diff --git a/internal/controller/typesenseapikey_controller.go b/internal/controller/typesenseapikey_controller.go new file mode 100644 index 0000000..27c71b4 --- /dev/null +++ b/internal/controller/typesenseapikey_controller.go @@ -0,0 +1,303 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "net/http" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" +) + +// apiKeyReconcileRequeuePeriod is how often a steady-state (already-created, unchanged) key is +// re-checked for drift. +var apiKeyReconcileRequeuePeriod = 5 * time.Minute + +// TypesenseApiKeyReconciler reconciles a TypesenseApiKey object +type TypesenseApiKeyReconciler struct { + client.Client + Scheme *runtime.Scheme + logger logr.Logger + Recorder record.EventRecorder + HttpClient *http.Client +} + +// +kubebuilder:rbac:groups=ts.opentelekomcloud.com,resources=typesenseapikeys,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=ts.opentelekomcloud.com,resources=typesenseapikeys/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=ts.opentelekomcloud.com,resources=typesenseapikeys/finalizers,verbs=update +// +kubebuilder:rbac:groups=ts.opentelekomcloud.com,resources=typesenseclusters,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete + +// Reconcile is part of the main kubernetes reconciliation loop for TypesenseApiKey. It handles +// first-ever create, spec-change rotation, steady-state drift detection, and finalizer-guarded +// delete. +func (r *TypesenseApiKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.logger = log.Log.WithValues("namespace", req.Namespace, "apikey", req.Name) + + var key tsv1alpha1.TypesenseApiKey + if err := r.Get(ctx, req.NamespacedName, &key); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + r.logger.Info("reconciling api key") + + if !key.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, &key) + } + + if !controllerutil.ContainsFinalizer(&key, ApiKeyFinalizer) { + controllerutil.AddFinalizer(&key, ApiKeyFinalizer) + if err := r.Update(ctx, &key); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + + if err := r.initConditions(ctx, &key); err != nil { + return ctrl.Result{}, err + } + + ts, err := r.resolveCluster(ctx, &key) + if err != nil { + r.logger.Error(err, "resolving typesense cluster failed") + if cerr := r.setConditionNotReady(ctx, &key, ApiKeyConditionReasonClusterNotFound, err); cerr != nil { + return ctrl.Result{}, cerr + } + return ctrl.Result{RequeueAfter: apiKeyReconcileRequeuePeriod}, nil + } + + adminKey, err := r.getAdminApiKey(ctx, ts) + if err != nil { + r.logger.Error(err, "resolving admin api key failed") + if cerr := r.setConditionNotReady(ctx, &key, ApiKeyConditionReasonAdminKeySecretNotReady, err); cerr != nil { + return ctrl.Result{}, cerr + } + return ctrl.Result{RequeueAfter: apiKeyReconcileRequeuePeriod}, nil + } + + switch { + case key.Status.KeyId == nil: + if err := r.createApiKey(ctx, &key, ts, adminKey); err != nil { + r.logger.Error(err, "creating typesense api key failed") + if cerr := r.setConditionNotReady(ctx, &key, ApiKeyConditionReasonKeyCreateFailed, err); cerr != nil { + return ctrl.Result{}, cerr + } + return ctrl.Result{RequeueAfter: apiKeyReconcileRequeuePeriod}, nil + } + case key.Generation != key.Status.ObservedGeneration: + if err := r.rotateApiKey(ctx, &key, ts, adminKey); err != nil { + r.logger.Error(err, "rotating typesense api key failed") + if cerr := r.setConditionNotReady(ctx, &key, ApiKeyConditionReasonKeyRotateFailed, err); cerr != nil { + return ctrl.Result{}, cerr + } + return ctrl.Result{RequeueAfter: apiKeyReconcileRequeuePeriod}, nil + } + default: + if err := r.checkDrift(ctx, &key, ts, adminKey); err != nil { + r.logger.Error(err, "checking typesense api key for drift failed") + if cerr := r.setConditionNotReady(ctx, &key, ApiKeyConditionReasonKeyDriftCheckFailed, err); cerr != nil { + return ctrl.Result{}, cerr + } + return ctrl.Result{RequeueAfter: apiKeyReconcileRequeuePeriod}, nil + } + } + + if err := r.setConditionReady(ctx, &key, ApiKeyConditionReasonReady); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{RequeueAfter: apiKeyReconcileRequeuePeriod}, nil +} + +// createApiKey creates a brand-new Typesense key from the CR's spec, stores its value in a +// Secret, and records the result in status. +func (r *TypesenseApiKeyReconciler) createApiKey(ctx context.Context, key *tsv1alpha1.TypesenseApiKey, ts *tsv1alpha1.TypesenseCluster, adminKey []byte) error { + var expiresAt *int64 + if key.Spec.ExpiresAt != nil { + unix := key.Spec.ExpiresAt.Unix() + expiresAt = &unix + } + + created, err := r.createKey(ctx, ts, adminKey, CreateKeyRequest{ + Description: key.Spec.Description, + Actions: key.Spec.Actions, + Collections: key.Spec.Collections, + ExpiresAt: expiresAt, + Value: key.Spec.Value, + }) + if err != nil { + return err + } + + secret, err := r.ReconcileSecret(ctx, key, created.Value) + if err != nil { + return err + } + + return r.patchStatus(ctx, key, func(status *tsv1alpha1.TypesenseApiKeyStatus) { + status.KeyId = &created.Id + status.ValuePrefix = created.ValuePrefix + status.ObservedGeneration = key.Generation + status.SecretRef = corev1.LocalObjectReference{Name: secret.Name} + }) +} + +// rotateApiKey is invoked when the CR's spec has changed since the last successful reconcile +// (key.Generation != key.Status.ObservedGeneration). Typesense keys are immutable, so rotation +// means deleting the old remote key and creating a new one from the current spec, then updating +// the Secret and status to point at it. The old key is deleted first so a stale key from a +// removed action/collection never outlives its replacement. +func (r *TypesenseApiKeyReconciler) rotateApiKey(ctx context.Context, key *tsv1alpha1.TypesenseApiKey, ts *tsv1alpha1.TypesenseCluster, adminKey []byte) error { + if key.Status.KeyId != nil { + if err := r.deleteKey(ctx, ts, adminKey, *key.Status.KeyId); err != nil { + return err + } + } + + return r.createApiKey(ctx, key, ts, adminKey) +} + +// checkDrift re-fetches the remote key on every steady-state reconcile (at +// apiKeyReconcileRequeuePeriod cadence) and heals it if it was deleted or edited out-of-band, +// e.g. directly through the Typesense API: a missing key is recreated, a key whose +// description/actions/collections no longer match the spec is rotated. +func (r *TypesenseApiKeyReconciler) checkDrift(ctx context.Context, key *tsv1alpha1.TypesenseApiKey, ts *tsv1alpha1.TypesenseCluster, adminKey []byte) error { + remote, err := r.getKey(ctx, ts, adminKey, *key.Status.KeyId) + if err != nil { + if !isNotFoundErr(err) { + return err + } + + r.logger.Info("remote typesense api key no longer exists, recreating", "keyId", *key.Status.KeyId) + return r.createApiKey(ctx, key, ts, adminKey) + } + + if keySpecMatchesRemote(key, remote) { + return nil + } + + r.logger.Info("remote typesense api key drifted from spec, rotating", "keyId", *key.Status.KeyId) + return r.rotateApiKey(ctx, key, ts, adminKey) +} + +// reconcileDelete deletes the corresponding Typesense key (best-effort - tolerates the owning +// cluster or its admin key already being gone, e.g. because the cluster was deleted first) before +// removing the finalizer so the CR can actually be garbage collected. A genuine failure to reach +// an otherwise-resolvable cluster is returned as an error so the finalizer stays and this gets +// retried, rather than risking an orphaned remote key. +func (r *TypesenseApiKeyReconciler) reconcileDelete(ctx context.Context, key *tsv1alpha1.TypesenseApiKey) (ctrl.Result, error) { + if !controllerutil.ContainsFinalizer(key, ApiKeyFinalizer) { + return ctrl.Result{}, nil + } + + if key.Status.KeyId != nil { + ts, err := r.resolveCluster(ctx, key) + if err != nil { + r.logger.Info("typesense cluster no longer resolvable, skipping remote key deletion", "reason", err.Error()) + } else if adminKey, err := r.getAdminApiKey(ctx, ts); err != nil { + r.logger.Info("admin api key no longer resolvable, skipping remote key deletion", "reason", err.Error()) + } else if err := r.deleteKey(ctx, ts, adminKey, *key.Status.KeyId); err != nil { + r.logger.Error(err, "deleting typesense api key failed") + return ctrl.Result{}, err + } + } + + controllerutil.RemoveFinalizer(key, ApiKeyFinalizer) + if err := r.Update(ctx, key); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *TypesenseApiKeyReconciler) initConditions(ctx context.Context, key *tsv1alpha1.TypesenseApiKey) error { + if len(key.Status.Conditions) == 0 { + if err := r.patchStatus(ctx, key, func(status *tsv1alpha1.TypesenseApiKeyStatus) { + meta.SetStatusCondition(&key.Status.Conditions, metav1.Condition{ + Type: ApiKeyConditionTypeReady, + Status: metav1.ConditionUnknown, + Reason: ApiKeyConditionReasonReconciliationInProgress, + Message: ApiKeyInitReconciliationMessage, + }) + status.Phase = "Pending" + }); err != nil { + r.logger.Error(err, ApiKeyUpdateStatusMessageFailed) + return err + } + } + return nil +} + +func (r *TypesenseApiKeyReconciler) setConditionNotReady(ctx context.Context, key *tsv1alpha1.TypesenseApiKey, reason string, err error) error { + return r.patchStatus(ctx, key, func(status *tsv1alpha1.TypesenseApiKeyStatus) { + meta.SetStatusCondition(&key.Status.Conditions, metav1.Condition{ + Type: ApiKeyConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) + status.Phase = reason + }) +} + +func (r *TypesenseApiKeyReconciler) setConditionReady(ctx context.Context, key *tsv1alpha1.TypesenseApiKey, reason string) error { + return r.patchStatus(ctx, key, func(status *tsv1alpha1.TypesenseApiKeyStatus) { + meta.SetStatusCondition(&key.Status.Conditions, metav1.Condition{ + Type: ApiKeyConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: reason, + Message: "Api Key is Ready", + }) + status.Phase = reason + }) +} + +func (r *TypesenseApiKeyReconciler) patchStatus( + ctx context.Context, + key *tsv1alpha1.TypesenseApiKey, + patcher func(status *tsv1alpha1.TypesenseApiKeyStatus), +) error { + patch := client.MergeFrom(key.DeepCopy()) + patcher(&key.Status) + + if err := r.Status().Patch(ctx, key, patch); err != nil { + r.logger.Error(err, "unable to patch typesense api key status") + return err + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TypesenseApiKeyReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&tsv1alpha1.TypesenseApiKey{}, eventFilters). + Named("typesense-apikey-controller"). + Complete(r) +} diff --git a/internal/controller/typesenseapikey_helpers.go b/internal/controller/typesenseapikey_helpers.go new file mode 100644 index 0000000..dc2e29d --- /dev/null +++ b/internal/controller/typesenseapikey_helpers.go @@ -0,0 +1,83 @@ +package controller + +import ( + "context" + "fmt" + "slices" + + tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *TypesenseApiKeyReconciler) resolveCluster(ctx context.Context, key *tsv1alpha1.TypesenseApiKey) (*tsv1alpha1.TypesenseCluster, error) { + namespace := key.Spec.ClusterRef.Namespace + if namespace == "" { + namespace = key.Namespace + } + + var ts tsv1alpha1.TypesenseCluster + clusterObjectKey := client.ObjectKey{Namespace: namespace, Name: key.Spec.ClusterRef.Name} + + if err := r.Get(ctx, clusterObjectKey, &ts); err != nil { + return nil, fmt.Errorf("resolving typesense cluster %s: %w", clusterObjectKey, err) + } + + return &ts, nil +} + +// getAdminApiKey fetches the plaintext admin api key of the referenced TypesenseCluster, using +// the same object-key resolution logic as typesensecluster_secret.go:getAdminApiKeyObjectKey. +func (r *TypesenseApiKeyReconciler) getAdminApiKey(ctx context.Context, ts *tsv1alpha1.TypesenseCluster) ([]byte, error) { + secretObjectKey := adminApiKeySecretObjectKey(ts) + + var secret v1.Secret + if err := r.Get(ctx, secretObjectKey, &secret); err != nil { + return nil, fmt.Errorf("fetching admin api key secret %s: %w", secretObjectKey, err) + } + + adminKey, ok := secret.Data[ClusterAdminApiKeySecretKeyName] + if !ok { + return nil, fmt.Errorf("admin api key secret %s is missing key %q", secretObjectKey, ClusterAdminApiKeySecretKeyName) + } + + return adminKey, nil +} + +// keySpecMatchesRemote reports whether the CR's spec still matches what Typesense holds for the +// key, ignoring order in the actions/collections lists. expires_at and value are not compared: +// Typesense's GET /keys/{id} never returns the plaintext value, and expiry drift is harmless +// (Typesense enforces it server-side regardless of what's mirrored into status). +func keySpecMatchesRemote(key *tsv1alpha1.TypesenseApiKey, remote *KeyResponse) bool { + if key.Spec.Description != remote.Description { + return false + } + + return stringSetsEqual(key.Spec.Actions, remote.Actions) && stringSetsEqual(key.Spec.Collections, remote.Collections) +} + +func stringSetsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + a, b = slices.Clone(a), slices.Clone(b) + slices.Sort(a) + slices.Sort(b) + + return slices.Equal(a, b) +} + +func adminApiKeySecretObjectKey(ts *tsv1alpha1.TypesenseCluster) client.ObjectKey { + if ts.Spec.AdminApiKey != nil { + return client.ObjectKey{ + Namespace: ts.Namespace, + Name: ts.Spec.AdminApiKey.Name, + } + } + + return client.ObjectKey{ + Namespace: ts.Namespace, + Name: fmt.Sprintf(ClusterAdminApiKeySecret, ts.Name), + } +} diff --git a/internal/controller/typesenseapikey_secret.go b/internal/controller/typesenseapikey_secret.go new file mode 100644 index 0000000..8d95c51 --- /dev/null +++ b/internal/controller/typesenseapikey_secret.go @@ -0,0 +1,73 @@ +package controller + +import ( + "context" + "fmt" + + tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ReconcileSecret ensures a Secret holding the current plaintext key value exists and matches +// value. Unlike the TypesenseCluster admin key secret, this Secret is not immutable: rotation +// updates it in place. +func (r *TypesenseApiKeyReconciler) ReconcileSecret(ctx context.Context, key *tsv1alpha1.TypesenseApiKey, value string) (*v1.Secret, error) { + secretObjectKey := getApiKeySecretObjectKey(key) + + var secret v1.Secret + err := r.Get(ctx, secretObjectKey, &secret) + if err != nil && !apierrors.IsNotFound(err) { + r.logger.Error(err, "unable to fetch secret", "secret", secretObjectKey) + return nil, err + } + + if apierrors.IsNotFound(err) { + secret = v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretObjectKey.Name, + Namespace: secretObjectKey.Namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "typesense-operator", + "app.kubernetes.io/name": "typesense-api-key", + "app.kubernetes.io/instance": key.Name, + }, + }, + Type: v1.SecretTypeOpaque, + Data: map[string][]byte{ + ApiKeySecretKeyName: []byte(value), + }, + } + + if err := ctrl.SetControllerReference(key, &secret, r.Scheme); err != nil { + return nil, err + } + + if err := r.Create(ctx, &secret); err != nil { + r.logger.Error(err, "creating api key secret failed", "secret", secretObjectKey) + return nil, err + } + + return &secret, nil + } + + if string(secret.Data[ApiKeySecretKeyName]) != value { + secret.Data[ApiKeySecretKeyName] = []byte(value) + if err := r.Update(ctx, &secret); err != nil { + r.logger.Error(err, "updating api key secret failed", "secret", secretObjectKey) + return nil, err + } + } + + return &secret, nil +} + +func getApiKeySecretObjectKey(key *tsv1alpha1.TypesenseApiKey) client.ObjectKey { + return client.ObjectKey{ + Namespace: key.Namespace, + Name: fmt.Sprintf(ApiKeySecretName, key.Name), + } +} From 9a7d5ca614f054646180eb302d2843e9e3c7078d Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 10:03:00 -0400 Subject: [PATCH 04/19] fix: correct always-true condition in StatefulSet scale guard condition.Reason != A || condition.Reason != B is a tautology, so the guard never actually skipped SpecReplicasChanged/emergency updates while quorum was downgraded or had queued writes. Change to && to match the intended "skip only when neither reason applies" behavior. Also fills in the scaffolded TypesenseCluster controller test, which never set spec.image/spec.storage (now required) or wired a DiscoveryClient, so it failed validation/panicked before ever exercising the reconcile loop. --- .../typesensecluster_controller_test.go | 20 +++++++++++++++---- .../typesensecluster_statefulset_hash.go | 4 ++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/controller/typesensecluster_controller_test.go b/internal/controller/typesensecluster_controller_test.go index 25fbadc..e6b865c 100644 --- a/internal/controller/typesensecluster_controller_test.go +++ b/internal/controller/typesensecluster_controller_test.go @@ -22,7 +22,9 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" "sigs.k8s.io/controller-runtime/pkg/reconcile" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -51,7 +53,13 @@ var _ = Describe("TypesenseCluster Controller", func() { Name: resourceName, Namespace: "default", }, - // TODO(user): Specify other spec details if needed. + Spec: tsv1alpha1.TypesenseClusterSpec{ + Image: "typesense/typesense:27.1", + Storage: &tsv1alpha1.StorageSpec{ + Size: resource.MustParse("100Mi"), + StorageClassName: "standard", + }, + }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } @@ -68,12 +76,16 @@ var _ = Describe("TypesenseCluster Controller", func() { }) It("should successfully reconcile the resource", func() { By("Reconciling the created resource") + clientSet, err := kubernetes.NewForConfig(cfg) + Expect(err).NotTo(HaveOccurred()) + controllerReconciler := &TypesenseClusterReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + DiscoveryClient: clientSet.DiscoveryClient, } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ NamespacedName: typeNamespacedName, }) Expect(err).NotTo(HaveOccurred()) diff --git a/internal/controller/typesensecluster_statefulset_hash.go b/internal/controller/typesensecluster_statefulset_hash.go index f964e60..7011d28 100644 --- a/internal/controller/typesensecluster_statefulset_hash.go +++ b/internal/controller/typesensecluster_statefulset_hash.go @@ -43,7 +43,7 @@ func (r *TypesenseClusterReconciler) shouldUpdateStatefulSet(sts *appsv1.Statefu // SpecReplicasChanged if *sts.Spec.Replicas != ts.Spec.Replicas && - (condition.Reason != string(ConditionReasonQuorumDowngraded) || condition.Reason != string(ConditionReasonQuorumQueuedWrites)) { + (condition.Reason != string(ConditionReasonQuorumDowngraded) && condition.Reason != string(ConditionReasonQuorumQueuedWrites)) { triggers = append(triggers, SpecReplicasChanged) update = false scaleOnly = true @@ -121,7 +121,7 @@ func (r *TypesenseClusterReconciler) shouldEmergencyUpdateStatefulSet(sts *appsv } if *sts.Spec.Replicas != ts.Spec.Replicas && - (condition.Reason != string(ConditionReasonQuorumDowngraded) || condition.Reason != string(ConditionReasonQuorumQueuedWrites)) { + (condition.Reason != string(ConditionReasonQuorumDowngraded) && condition.Reason != string(ConditionReasonQuorumQueuedWrites)) { return true } From c7d73bcf5942d1fe9e139e2feb8cb492727fd424 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 10:03:30 -0400 Subject: [PATCH 05/19] test: add envtest coverage for TypesenseApiKeyReconciler Covers create, spec-change rotation, drift detection (remote key deleted or edited out-of-band), and creating a key for a TypesenseCluster in a different namespace. Uses an in-memory fake of the Typesense /keys REST API plus a redirecting http.RoundTripper, since buildKeysUrl always targets a k8s in-cluster Service DNS name that doesn't resolve under envtest. --- .../typesenseapikey_controller_test.go | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 internal/controller/typesenseapikey_controller_test.go diff --git a/internal/controller/typesenseapikey_controller_test.go b/internal/controller/typesenseapikey_controller_test.go new file mode 100644 index 0000000..2c5ebc7 --- /dev/null +++ b/internal/controller/typesenseapikey_controller_test.go @@ -0,0 +1,459 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" +) + +// fakeKeysServer is a minimal in-memory stand-in for the Typesense /keys REST API, used so the +// TypesenseApiKeyReconciler tests can exercise real HTTP request/response handling without a +// live Typesense cluster. It keeps created keys in memory so GET can reflect drift (or a +// disappearance) injected directly through forget()/mutate() to simulate out-of-band changes. +type fakeKeysServer struct { + mu sync.Mutex + nextId int64 + keys map[int64]KeyResponse + deleted []int64 +} + +func newFakeKeysServer() (*httptest.Server, *fakeKeysServer) { + f := &fakeKeysServer{nextId: 1, keys: map[int64]KeyResponse{}} + + mux := http.NewServeMux() + mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + var req CreateKeyRequest + Expect(json.NewDecoder(r.Body).Decode(&req)).To(Succeed()) + + f.mu.Lock() + id := f.nextId + f.nextId++ + + value := fmt.Sprintf("secret-value-%d", id) + if req.Value != nil { + value = *req.Value + } + + resp := KeyResponse{ + Id: id, + Description: req.Description, + Actions: req.Actions, + Collections: req.Collections, + Value: value, + ValuePrefix: value[:4], + } + f.keys[id] = resp + f.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(resp)).To(Succeed()) + }) + mux.HandleFunc("/keys/", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(strings.TrimPrefix(r.URL.Path, "/keys/"), 10, 64) + Expect(err).NotTo(HaveOccurred()) + + switch r.Method { + case http.MethodGet: + f.mu.Lock() + resp, ok := f.keys[id] + f.mu.Unlock() + + if !ok { + w.WriteHeader(http.StatusNotFound) + Expect(json.NewEncoder(w).Encode(typesenseApiError{Message: "key not found"})).To(Succeed()) + return + } + + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(resp)).To(Succeed()) + case http.MethodDelete: + f.mu.Lock() + delete(f.keys, id) + f.deleted = append(f.deleted, id) + f.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(deleteKeyResponse{Id: id})).To(Succeed()) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + return httptest.NewServer(mux), f +} + +// forget simulates the remote key having disappeared out-of-band (e.g. deleted directly through +// the Typesense API, bypassing this operator). +func (f *fakeKeysServer) forget(id int64) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.keys, id) +} + +// mutate simulates the remote key having been edited out-of-band. +func (f *fakeKeysServer) mutate(id int64, fn func(resp KeyResponse) KeyResponse) { + f.mu.Lock() + defer f.mu.Unlock() + f.keys[id] = fn(f.keys[id]) +} + +// redirectTransport forwards every request to targetURL regardless of what host/scheme the +// request was originally built for - buildKeysUrl always targets a k8s in-cluster Service DNS +// name that doesn't resolve in envtest, so requests are rewritten onto the fakeKeysServer instead. +type redirectTransport struct { + targetURL *url.URL +} + +func (t *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = t.targetURL.Scheme + req.URL.Host = t.targetURL.Host + req.Host = t.targetURL.Host + return http.DefaultTransport.RoundTrip(req) +} + +var _ = Describe("TypesenseApiKey Controller", func() { + const clusterName = "apikey-test-cluster" + const namespace = "default" + + ctx := context.Background() + + var ( + server *httptest.Server + fakeKeys *fakeKeysServer + reconciler *TypesenseApiKeyReconciler + cluster *tsv1alpha1.TypesenseCluster + adminSecretName string + ) + + BeforeEach(func() { + server, fakeKeys = newFakeKeysServer() + targetURL, err := url.Parse(server.URL) + Expect(err).NotTo(HaveOccurred()) + + cluster = &tsv1alpha1.TypesenseCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + }, + Spec: tsv1alpha1.TypesenseClusterSpec{ + Image: "typesense/typesense:27.1", + Storage: &tsv1alpha1.StorageSpec{ + StorageClassName: "standard", + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + adminSecretName = fmt.Sprintf(ClusterAdminApiKeySecret, clusterName) + adminSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: adminSecretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + ClusterAdminApiKeySecretKeyName: []byte("admin-secret-key"), + }, + } + Expect(k8sClient.Create(ctx, adminSecret)).To(Succeed()) + + reconciler = &TypesenseApiKeyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + HttpClient: &http.Client{Transport: &redirectTransport{targetURL: targetURL}}, + } + }) + + AfterEach(func() { + server.Close() + + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: adminSecretName, Namespace: namespace}, + })).To(Succeed()) + }) + + It("creates a remote key and a Secret holding its value, then cleans both up on delete", func() { + key := &tsv1alpha1.TypesenseApiKey{ + ObjectMeta: metav1.ObjectMeta{Name: "test-key", Namespace: namespace}, + Spec: tsv1alpha1.TypesenseApiKeySpec{ + ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, + Description: "test key", + Actions: []string{"documents:search"}, + Collections: []string{"*"}, + }, + } + Expect(k8sClient.Create(ctx, key)).To(Succeed()) + nn := types.NamespacedName{Name: key.Name, Namespace: namespace} + + By("adding the finalizer on the first reconcile") + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + By("creating the remote key on the second reconcile") + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + var got tsv1alpha1.TypesenseApiKey + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(got.Status.KeyId).NotTo(BeNil()) + Expect(*got.Status.KeyId).To(Equal(int64(1))) + Expect(got.Status.ObservedGeneration).To(Equal(got.Generation)) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, ApiKeyConditionTypeReady)).To(BeTrue()) + + var secret corev1.Secret + secretKey := types.NamespacedName{Name: fmt.Sprintf(ApiKeySecretName, key.Name), Namespace: namespace} + Expect(k8sClient.Get(ctx, secretKey, &secret)).To(Succeed()) + Expect(string(secret.Data[ApiKeySecretKeyName])).To(Equal("secret-value-1")) + + By("deleting the remote key when the CR is deleted") + Expect(k8sClient.Delete(ctx, &got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + Expect(errors.IsNotFound(k8sClient.Get(ctx, nn, &got))).To(BeTrue()) + Expect(fakeKeys.deleted).To(ConsistOf(int64(1))) + }) + + It("rotates the remote key when the spec changes", func() { + key := &tsv1alpha1.TypesenseApiKey{ + ObjectMeta: metav1.ObjectMeta{Name: "rotate-key", Namespace: namespace}, + Spec: tsv1alpha1.TypesenseApiKeySpec{ + ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, + Description: "rotate key", + Actions: []string{"documents:search"}, + Collections: []string{"*"}, + }, + } + Expect(k8sClient.Create(ctx, key)).To(Succeed()) + nn := types.NamespacedName{Name: key.Name, Namespace: namespace} + + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + var got tsv1alpha1.TypesenseApiKey + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(*got.Status.KeyId).To(Equal(int64(1))) + firstGeneration := got.Generation + + By("changing the spec so a new generation is observed") + got.Spec.Collections = []string{"other-collection"} + Expect(k8sClient.Update(ctx, &got)).To(Succeed()) + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(got.Generation).To(BeNumerically(">", firstGeneration)) + + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(*got.Status.KeyId).To(Equal(int64(2))) + Expect(got.Status.ObservedGeneration).To(Equal(got.Generation)) + Expect(fakeKeys.deleted).To(ConsistOf(int64(1))) + + var secret corev1.Secret + secretKey := types.NamespacedName{Name: fmt.Sprintf(ApiKeySecretName, key.Name), Namespace: namespace} + Expect(k8sClient.Get(ctx, secretKey, &secret)).To(Succeed()) + Expect(string(secret.Data[ApiKeySecretKeyName])).To(Equal("secret-value-2")) + + Expect(k8sClient.Delete(ctx, &got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("recreates the remote key when it disappears out-of-band", func() { + key := &tsv1alpha1.TypesenseApiKey{ + ObjectMeta: metav1.ObjectMeta{Name: "drift-missing-key", Namespace: namespace}, + Spec: tsv1alpha1.TypesenseApiKeySpec{ + ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, + Description: "drift key", + Actions: []string{"documents:search"}, + Collections: []string{"*"}, + }, + } + Expect(k8sClient.Create(ctx, key)).To(Succeed()) + nn := types.NamespacedName{Name: key.Name, Namespace: namespace} + + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + var got tsv1alpha1.TypesenseApiKey + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(*got.Status.KeyId).To(Equal(int64(1))) + + By("deleting the key directly through the fake Typesense API") + fakeKeys.forget(1) + + By("healing the drift on the next steady-state reconcile") + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(*got.Status.KeyId).To(Equal(int64(2))) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, ApiKeyConditionTypeReady)).To(BeTrue()) + + Expect(k8sClient.Delete(ctx, &got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("rotates the remote key when it was edited out-of-band", func() { + key := &tsv1alpha1.TypesenseApiKey{ + ObjectMeta: metav1.ObjectMeta{Name: "drift-mutated-key", Namespace: namespace}, + Spec: tsv1alpha1.TypesenseApiKeySpec{ + ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, + Description: "drift key", + Actions: []string{"documents:search"}, + Collections: []string{"*"}, + }, + } + Expect(k8sClient.Create(ctx, key)).To(Succeed()) + nn := types.NamespacedName{Name: key.Name, Namespace: namespace} + + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + var got tsv1alpha1.TypesenseApiKey + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(*got.Status.KeyId).To(Equal(int64(1))) + + By("editing the key's actions directly through the fake Typesense API") + fakeKeys.mutate(1, func(resp KeyResponse) KeyResponse { + resp.Actions = []string{"documents:*"} + return resp + }) + + By("healing the drift on the next steady-state reconcile") + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(*got.Status.KeyId).To(Equal(int64(2))) + Expect(fakeKeys.deleted).To(ConsistOf(int64(1))) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, ApiKeyConditionTypeReady)).To(BeTrue()) + + Expect(k8sClient.Delete(ctx, &got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates a key for a TypesenseCluster in a different namespace", func() { + otherNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "apikey-cross-ns-test"}, + } + Expect(k8sClient.Create(ctx, otherNamespace)).To(Succeed()) + defer func() { + Expect(k8sClient.Delete(ctx, otherNamespace)).To(Succeed()) + }() + + remoteClusterName := "cross-ns-cluster" + remoteCluster := &tsv1alpha1.TypesenseCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: remoteClusterName, + Namespace: otherNamespace.Name, + }, + Spec: tsv1alpha1.TypesenseClusterSpec{ + Image: "typesense/typesense:27.1", + Storage: &tsv1alpha1.StorageSpec{ + StorageClassName: "standard", + }, + }, + } + Expect(k8sClient.Create(ctx, remoteCluster)).To(Succeed()) + defer func() { + Expect(k8sClient.Delete(ctx, remoteCluster)).To(Succeed()) + }() + + remoteAdminSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf(ClusterAdminApiKeySecret, remoteClusterName), + Namespace: otherNamespace.Name, + }, + Data: map[string][]byte{ + ClusterAdminApiKeySecretKeyName: []byte("admin-secret-key"), + }, + } + Expect(k8sClient.Create(ctx, remoteAdminSecret)).To(Succeed()) + defer func() { + Expect(k8sClient.Delete(ctx, remoteAdminSecret)).To(Succeed()) + }() + + key := &tsv1alpha1.TypesenseApiKey{ + ObjectMeta: metav1.ObjectMeta{Name: "cross-ns-key", Namespace: namespace}, + Spec: tsv1alpha1.TypesenseApiKeySpec{ + ClusterRef: tsv1alpha1.TypesenseClusterReference{ + Name: remoteClusterName, + Namespace: otherNamespace.Name, + }, + Description: "cross namespace key", + Actions: []string{"documents:search"}, + Collections: []string{"*"}, + }, + } + Expect(k8sClient.Create(ctx, key)).To(Succeed()) + nn := types.NamespacedName{Name: key.Name, Namespace: namespace} + + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + var got tsv1alpha1.TypesenseApiKey + Expect(k8sClient.Get(ctx, nn, &got)).To(Succeed()) + Expect(got.Status.KeyId).NotTo(BeNil()) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, ApiKeyConditionTypeReady)).To(BeTrue()) + + By("writing the Secret in the TypesenseApiKey's own namespace, not the cluster's") + var secret corev1.Secret + secretKey := types.NamespacedName{Name: fmt.Sprintf(ApiKeySecretName, key.Name), Namespace: namespace} + Expect(k8sClient.Get(ctx, secretKey, &secret)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, &got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + }) +}) From 7661c7ec511e5b7c5d97300643c87641f6d2775f Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 14:55:22 -0400 Subject: [PATCH 06/19] chore: rename container registry to ghcr.io/interworks, pin golangci-lint Migrates the default image registry from quay.io/akyriako to ghcr.io/interworks in the Makefile, and pins golangci-lint 2.13.1 via mise, updating .golangci.yml to the v2 config schema it requires. Also fixes two Makefile e2e defaults left over from the memcached kubebuilder scaffold: KIND_CLUSTER now defaults to typesense-operator-test-e2e instead of memcached-operator-test-e2e, and bundle-build uses $(CONTAINER_TOOL) instead of a hardcoded docker. Wraps the response body Close() in doKeysRequest to satisfy errcheck under the updated lint config. --- .golangci.yml | 56 ++++++++++--------- Makefile | 9 +-- internal/controller/typesenseapikey_client.go | 2 +- mise.toml | 1 + 4 files changed, 37 insertions(+), 31 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index dc35698..a7246fb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,33 +1,15 @@ +version: "2" run: - timeout: 5m allow-parallel-runners: true - -issues: - # don't skip warning about doc comments - # don't exclude the default set of lint - exclude-use-default: false - # restore some of the defaults - # (fill in the rest as needed) - exclude-rules: - - path: "api/*" - linters: - - lll - - path: "internal/*" - linters: - - dupl - - lll linters: - disable-all: true + default: none enable: + - copyloopvar - dupl - errcheck - - copyloopvar - ginkgolinter - goconst - gocyclo - - gofmt - - goimports - - gosimple - govet - ineffassign - lll @@ -36,12 +18,34 @@ linters: - prealloc - revive - staticcheck - - typecheck - unconvert - unparam - unused - -linters-settings: - revive: + settings: + revive: + rules: + - name: comment-spacings + exclusions: + generated: lax rules: - - name: comment-spacings \ No newline at end of file + - linters: + - lll + path: api/* + - linters: + - dupl + - lll + path: internal/* + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index 3da72f7..03e115f 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) # # For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both # opentelekomcloud.com/typesense-operator-bundle:$VERSION and opentelekomcloud.com/typesense-operator-catalog:$VERSION. -IMAGE_TAG_BASE ?= quay.io/akyriako/typesense-operator +IMAGE_TAG_BASE ?= ghcr.io/interworks/typesense-operator # BUNDLE_IMG defines the image:tag used for the bundle. # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) @@ -50,7 +50,7 @@ endif # This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. OPERATOR_SDK_VERSION ?= v1.39.0 # Image URL to use all building/pushing image targets -DOCKER_HUB_NAME ?= quay.io/akyriako#$(shell docker info | sed '/Username:/!d;s/.* //') +DOCKER_HUB_NAME ?= ghcr.io/interworks#$(shell docker info | sed '/Username:/!d;s/.* //') IMG_NAME ?= typesense-operator IMG_TAG ?= 0.4.1 IMG ?= $(DOCKER_HUB_NAME)/$(IMG_NAME):$(IMG_TAG) @@ -122,7 +122,8 @@ test: manifests generate fmt vet setup-envtest ## Run tests. # The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. # CertManager is installed by default; skip with: # - CERT_MANAGER_INSTALL_SKIP=true -KIND_CLUSTER ?= memcached-operator-test-e2e +KIND_CLUSTER ?= typesense-operator-test-e2e +KIND ?= kind .PHONY: setup-test-e2e setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist @@ -329,7 +330,7 @@ bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metada .PHONY: bundle-build bundle-build: ## Build the bundle image. - docker build -f bundle.Dockerfile -t $(BUNDLE_IMG) . + $(CONTAINER_TOOL) build -f bundle.Dockerfile -t $(BUNDLE_IMG) . .PHONY: bundle-push bundle-push: ## Push the bundle image. diff --git a/internal/controller/typesenseapikey_client.go b/internal/controller/typesenseapikey_client.go index 1330233..09a53a2 100644 --- a/internal/controller/typesenseapikey_client.go +++ b/internal/controller/typesenseapikey_client.go @@ -112,7 +112,7 @@ func (r *TypesenseApiKeyReconciler) doKeysRequest(ctx context.Context, method st if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/mise.toml b/mise.toml index d4cf6cc..65cfb62 100644 --- a/mise.toml +++ b/mise.toml @@ -2,3 +2,4 @@ kind = "0.32.0" kubectl = "1.36.2" helm = "4.2.4" +golangci-lint = "2.13.1" From 1fcee26d2332271165ed1bfb8295faa9a57fe9e8 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 14:55:36 -0400 Subject: [PATCH 07/19] chore: regenerate helm chart for TypesenseApiKey CRD and registry rename The TypesenseApiKey CRD/RBAC (feat: add TypesenseApiKey CRD and reconciler) was never propagated into the Helm chart. Re-running `make helmify` against the current kustomize output adds the typesenseapikey-crd.yaml template and the typesenseapikeys RBAC rules, and picks up the ghcr.io/interworks registry rename plus newer helmify's serviceAccountName/nodeSelector/tolerations/ topologySpreadConstraints conventions. --- .../templates/deployment.yaml | 8 +- .../templates/leader-election-rbac.yaml | 4 +- .../templates/manager-rbac.yaml | 7 +- .../templates/metrics-auth-rbac.yaml | 4 +- .../templates/metrics-reader-rbac.yaml | 2 +- .../templates/metrics-service.yaml | 2 +- .../templates/serviceaccount.yaml | 9 +- .../templates/typesenseapikey-crd.yaml | 200 ++++++++++++++++++ .../templates/typesensecluster-crd.yaml | 2 +- .../typesensecluster-editor-rbac.yaml | 2 +- .../typesensecluster-viewer-rbac.yaml | 2 +- charts/typesense-operator/values.yaml | 12 +- config/manager/kustomization.yaml | 2 +- 13 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 charts/typesense-operator/templates/typesenseapikey-crd.yaml diff --git a/charts/typesense-operator/templates/deployment.yaml b/charts/typesense-operator/templates/deployment.yaml index ac505f2..a49bce5 100644 --- a/charts/typesense-operator/templates/deployment.yaml +++ b/charts/typesense-operator/templates/deployment.yaml @@ -47,7 +47,11 @@ spec: securityContext: {{- toYaml .Values.controllerManager.manager.containerSecurityContext | nindent 10 }} imagePullSecrets: {{ .Values.imagePullSecrets | default list | toJson }} + nodeSelector: {{- toYaml .Values.controllerManager.nodeSelector | nindent 8 }} securityContext: {{- toYaml .Values.controllerManager.podSecurityContext | nindent 8 }} - serviceAccountName: {{ include "typesense-operator.fullname" . }}-controller-manager - terminationGracePeriodSeconds: 10 \ No newline at end of file + serviceAccountName: {{ include "typesense-operator.serviceAccountName" . }} + terminationGracePeriodSeconds: 10 + tolerations: {{- toYaml .Values.controllerManager.tolerations | nindent 8 }} + topologySpreadConstraints: {{- toYaml .Values.controllerManager.topologySpreadConstraints + | nindent 8 }} diff --git a/charts/typesense-operator/templates/leader-election-rbac.yaml b/charts/typesense-operator/templates/leader-election-rbac.yaml index 75d218b..fd45b82 100644 --- a/charts/typesense-operator/templates/leader-election-rbac.yaml +++ b/charts/typesense-operator/templates/leader-election-rbac.yaml @@ -49,5 +49,5 @@ roleRef: name: '{{ include "typesense-operator.fullname" . }}-leader-election-role' subjects: - kind: ServiceAccount - name: '{{ include "typesense-operator.fullname" . }}-controller-manager' - namespace: '{{ .Release.Namespace }}' \ No newline at end of file + name: '{{ include "typesense-operator.serviceAccountName" . }}' + namespace: '{{ .Release.Namespace }}' diff --git a/charts/typesense-operator/templates/manager-rbac.yaml b/charts/typesense-operator/templates/manager-rbac.yaml index f636516..139b209 100644 --- a/charts/typesense-operator/templates/manager-rbac.yaml +++ b/charts/typesense-operator/templates/manager-rbac.yaml @@ -125,6 +125,7 @@ rules: - apiGroups: - ts.opentelekomcloud.com resources: + - typesenseapikeys - typesenseclusters verbs: - create @@ -137,12 +138,14 @@ rules: - apiGroups: - ts.opentelekomcloud.com resources: + - typesenseapikeys/finalizers - typesenseclusters/finalizers verbs: - update - apiGroups: - ts.opentelekomcloud.com resources: + - typesenseapikeys/status - typesenseclusters/status verbs: - get @@ -161,5 +164,5 @@ roleRef: name: '{{ include "typesense-operator.fullname" . }}-manager-role' subjects: - kind: ServiceAccount - name: '{{ include "typesense-operator.fullname" . }}-controller-manager' - namespace: '{{ .Release.Namespace }}' \ No newline at end of file + name: '{{ include "typesense-operator.serviceAccountName" . }}' + namespace: '{{ .Release.Namespace }}' diff --git a/charts/typesense-operator/templates/metrics-auth-rbac.yaml b/charts/typesense-operator/templates/metrics-auth-rbac.yaml index e40bcab..8594e60 100644 --- a/charts/typesense-operator/templates/metrics-auth-rbac.yaml +++ b/charts/typesense-operator/templates/metrics-auth-rbac.yaml @@ -30,5 +30,5 @@ roleRef: name: '{{ include "typesense-operator.fullname" . }}-metrics-auth-role' subjects: - kind: ServiceAccount - name: '{{ include "typesense-operator.fullname" . }}-controller-manager' - namespace: '{{ .Release.Namespace }}' \ No newline at end of file + name: '{{ include "typesense-operator.serviceAccountName" . }}' + namespace: '{{ .Release.Namespace }}' diff --git a/charts/typesense-operator/templates/metrics-reader-rbac.yaml b/charts/typesense-operator/templates/metrics-reader-rbac.yaml index ca6e7e3..7423426 100644 --- a/charts/typesense-operator/templates/metrics-reader-rbac.yaml +++ b/charts/typesense-operator/templates/metrics-reader-rbac.yaml @@ -8,4 +8,4 @@ rules: - nonResourceURLs: - /metrics verbs: - - get \ No newline at end of file + - get diff --git a/charts/typesense-operator/templates/metrics-service.yaml b/charts/typesense-operator/templates/metrics-service.yaml index 81877f1..0ee163f 100644 --- a/charts/typesense-operator/templates/metrics-service.yaml +++ b/charts/typesense-operator/templates/metrics-service.yaml @@ -11,4 +11,4 @@ spec: control-plane: controller-manager {{- include "typesense-operator.selectorLabels" . | nindent 4 }} ports: - {{- .Values.metricsService.ports | toYaml | nindent 2 }} \ No newline at end of file + {{- .Values.metricsService.ports | toYaml | nindent 2 }} diff --git a/charts/typesense-operator/templates/serviceaccount.yaml b/charts/typesense-operator/templates/serviceaccount.yaml index c73d651..f83b41f 100644 --- a/charts/typesense-operator/templates/serviceaccount.yaml +++ b/charts/typesense-operator/templates/serviceaccount.yaml @@ -1,8 +1,13 @@ +{{ if .Values.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "typesense-operator.fullname" . }}-controller-manager + name: {{ include "typesense-operator.serviceAccountName" . }} labels: {{- include "typesense-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} annotations: - {{- toYaml .Values.controllerManager.serviceAccount.annotations | nindent 4 }} \ No newline at end of file + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/typesense-operator/templates/typesenseapikey-crd.yaml b/charts/typesense-operator/templates/typesenseapikey-crd.yaml new file mode 100644 index 0000000..6412041 --- /dev/null +++ b/charts/typesense-operator/templates/typesenseapikey-crd.yaml @@ -0,0 +1,200 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: typesenseapikeys.ts.opentelekomcloud.com + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + labels: + {{- include "typesense-operator.labels" . | nindent 4 }} +spec: + group: ts.opentelekomcloud.com + names: + kind: TypesenseApiKey + listKind: TypesenseApiKeyList + plural: typesenseapikeys + singular: typesenseapikey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .status.keyId + name: Key Id + type: integer + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: TypesenseApiKey is the Schema for the typesenseapikeys API + 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 + spec: + description: TypesenseApiKeySpec defines the desired state of TypesenseApiKey + properties: + actions: + items: + type: string + minItems: 1 + type: array + clusterRef: + description: |- + ClusterRef is the TypesenseCluster this key is issued against. If Namespace is omitted, the + TypesenseCluster is looked up in the TypesenseApiKey's own namespace. + properties: + name: + description: Name of the TypesenseCluster. + minLength: 1 + type: string + namespace: + description: Namespace of the TypesenseCluster. Defaults to the + TypesenseApiKey's own namespace. + type: string + required: + - name + type: object + collections: + items: + type: string + minItems: 1 + type: array + description: + minLength: 1 + type: string + expiresAt: + description: ExpiresAt maps to Typesense's expires_at (unix seconds). + format: date-time + type: string + value: + description: Value pins a specific key string instead of letting Typesense + auto-generate one. + type: string + required: + - actions + - clusterRef + - collections + - description + type: object + status: + description: TypesenseApiKeyStatus defines the observed state of TypesenseApiKey + properties: + conditions: + 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 + keyId: + description: KeyId is the numeric id Typesense assigned to this key, + needed to delete/rotate it. + format: int64 + type: integer + observedGeneration: + description: ObservedGeneration is the .metadata.generation last successfully + reconciled into a Typesense key. + format: int64 + type: integer + phase: + type: string + secretRef: + description: SecretRef is the name of the Secret holding the current + plaintext key value. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + valuePrefix: + description: ValuePrefix is the redacted prefix Typesense returns when + fetching a key, used for audit/drift display only. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/charts/typesense-operator/templates/typesensecluster-crd.yaml b/charts/typesense-operator/templates/typesensecluster-crd.yaml index c1c1f0b..d942b26 100644 --- a/charts/typesense-operator/templates/typesensecluster-crd.yaml +++ b/charts/typesense-operator/templates/typesensecluster-crd.yaml @@ -4890,4 +4890,4 @@ status: kind: "" plural: "" conditions: [] - storedVersions: [] \ No newline at end of file + storedVersions: [] diff --git a/charts/typesense-operator/templates/typesensecluster-editor-rbac.yaml b/charts/typesense-operator/templates/typesensecluster-editor-rbac.yaml index d18ddc9..87b65b8 100644 --- a/charts/typesense-operator/templates/typesensecluster-editor-rbac.yaml +++ b/charts/typesense-operator/templates/typesensecluster-editor-rbac.yaml @@ -22,4 +22,4 @@ rules: resources: - typesenseclusters/status verbs: - - get \ No newline at end of file + - get diff --git a/charts/typesense-operator/templates/typesensecluster-viewer-rbac.yaml b/charts/typesense-operator/templates/typesensecluster-viewer-rbac.yaml index c9ad825..60b27b8 100644 --- a/charts/typesense-operator/templates/typesensecluster-viewer-rbac.yaml +++ b/charts/typesense-operator/templates/typesensecluster-viewer-rbac.yaml @@ -18,4 +18,4 @@ rules: resources: - typesenseclusters/status verbs: - - get \ No newline at end of file + - get diff --git a/charts/typesense-operator/values.yaml b/charts/typesense-operator/values.yaml index 2caf892..35a8211 100644 --- a/charts/typesense-operator/values.yaml +++ b/charts/typesense-operator/values.yaml @@ -11,7 +11,7 @@ controllerManager: drop: - ALL image: - repository: quay.io/akyriako/typesense-operator + repository: ghcr.io/interworks/typesense-operator tag: 0.4.1 imagePullPolicy: IfNotPresent resources: @@ -21,11 +21,12 @@ controllerManager: requests: cpu: 10m memory: 64Mi + nodeSelector: {} podSecurityContext: runAsNonRoot: true replicas: 1 - serviceAccount: - annotations: {} + tolerations: [] + topologySpreadConstraints: [] imagePullSecrets: [] kubernetesClusterDomain: cluster.local metricsService: @@ -35,3 +36,8 @@ metricsService: protocol: TCP targetPort: 8443 type: ClusterIP +serviceAccount: + annotations: {} + automount: true + create: true + name: "" diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 2289514..a11faf3 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: quay.io/akyriako/typesense-operator + newName: ghcr.io/interworks/typesense-operator newTag: 0.4.1 From 5f8434c692d7e6f28c05e4af2d87143dc1f61a2e Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 14:55:47 -0400 Subject: [PATCH 08/19] fix: cap Typesense thread pool size in the cluster-1 sample Typesense defaults TYPESENSE_THREAD_POOL_SIZE to NUM_CORES * 8. On dev machines/CI runners with many cores but tightly resource-limited containers (e.g. a kind node under a nested VM), this can request more threads than the container can actually create, crashing with "terminate called after throwing std::system_error: Resource temporarily unavailable" right at startup. Caps it to 32 via the existing additionalServerConfiguration ConfigMap mechanism, the same pattern already used by ts_v1alpha1_typesensecluster_kind.yaml for other env overrides. --- config/samples/ts_v1alpha1_typesensecluster.yaml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/config/samples/ts_v1alpha1_typesensecluster.yaml b/config/samples/ts_v1alpha1_typesensecluster.yaml index 19f1251..374dd6e 100644 --- a/config/samples/ts_v1alpha1_typesensecluster.yaml +++ b/config/samples/ts_v1alpha1_typesensecluster.yaml @@ -1,3 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cluster-1-server-configuration +data: + # Typesense defaults this to NUM_CORES * 8, which can spawn more threads than the + # container/host can actually create (crashes with "Resource temporarily unavailable" + # on dev machines/CI runners with many cores but tight container resource limits). + TYPESENSE_THREAD_POOL_SIZE: "32" +--- apiVersion: ts.opentelekomcloud.com/v1alpha1 kind: TypesenseCluster metadata: @@ -10,4 +20,6 @@ spec: replicas: 3 storage: size: 100Mi - storageClassName: standard \ No newline at end of file + storageClassName: standard + additionalServerConfiguration: + name: cluster-1-server-configuration \ No newline at end of file From 24c5f3e899fa4ef825240e454b773c868b3d086f Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 14:56:04 -0400 Subject: [PATCH 09/19] test: add e2e coverage for TypesenseCluster quorum and TypesenseApiKey lifecycle The existing e2e suite only checked that the controller-manager pod came up; it never exercised a real TypesenseCluster reaching Raft quorum or a TypesenseApiKey being reconciled against a live Typesense process. Adds a new "APIKey lifecycle" context that applies the cluster-1 and search-only-key samples, waits for both CRs' Ready condition (the cluster's Ready only flips once ReconcileQuorum reports quorum healthy), then proves the issued key actually works: a scoped search against a nonexistent collection must 404 (authenticated, not rejected), and a collection-create request with the same key must 401 (proves the documents:search-only scope is enforced by Typesense, not just recorded in status). Verification runs in throwaway curlimages/curl pods read back via `kubectl logs` after waiting for Succeeded, rather than `kubectl run --rm -i` attach output, which races short-lived commands and can silently return kubectl's own status text instead of the container's stdout. Also registers the search-only-key sample in config/samples/kustomization.yaml. --- config/samples/kustomization.yaml | 1 + .../samples/ts_v1alpha1_typesenseapikey.yaml | 15 ++ test/e2e/e2e_test.go | 128 ++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 config/samples/ts_v1alpha1_typesenseapikey.yaml diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index ff331dd..a8d1c22 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -7,4 +7,5 @@ resources: - ts_v1alpha1_typesensecluster_opentelekomcloud.yaml - ts_v1alpha1_typesensecluster.yaml - ts_v1alpha1_typesensecluster_gcp.yaml +- ts_v1alpha1_typesenseapikey.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/ts_v1alpha1_typesenseapikey.yaml b/config/samples/ts_v1alpha1_typesenseapikey.yaml new file mode 100644 index 0000000..1d3b060 --- /dev/null +++ b/config/samples/ts_v1alpha1_typesenseapikey.yaml @@ -0,0 +1,15 @@ +apiVersion: ts.opentelekomcloud.com/v1alpha1 +kind: TypesenseApiKey +metadata: + labels: + app.kubernetes.io/name: typesense-operator + app.kubernetes.io/managed-by: kustomize + name: search-only-key +spec: + clusterRef: + name: cluster-1 + description: read-only key for the search frontend + actions: + - documents:search + collections: + - "*" diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 73115eb..3c73ec5 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -17,8 +17,10 @@ limitations under the License. package e2e import ( + "encoding/base64" "fmt" "os/exec" + "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -29,6 +31,50 @@ import ( const namespace = "typesense-operator-system" +const ( + clusterSampleFile = "config/samples/ts_v1alpha1_typesensecluster.yaml" + clusterName = "cluster-1" + clusterServiceFQDN = clusterName + "-svc." + namespace + ".svc.cluster.local" + + apiKeySampleFile = "config/samples/ts_v1alpha1_typesenseapikey.yaml" + apiKeyName = "search-only-key" +) + +// runCurlPod runs script (a shell one-liner) to completion in a throwaway curlimages/curl pod +// and returns its stdout. It waits for the pod to reach Succeeded and reads via `kubectl logs` +// rather than `kubectl run --rm -i` attach output, since attach races the pod completing for +// short-lived commands and can silently return kubectl's own status messages instead of the +// container's output. +func runCurlPod(podName string, script string) (string, error) { + _, _ = utils.Run(exec.Command("kubectl", "delete", "pod", podName, "-n", namespace, "--ignore-not-found")) + defer func() { + _, _ = utils.Run(exec.Command("kubectl", "delete", "pod", podName, "-n", namespace, "--ignore-not-found")) + }() + + cmd := exec.Command("kubectl", "run", podName, + "-n", namespace, + "--image=curlimages/curl", + "--restart=Never", + "--command", "--", + "sh", "-c", script, + ) + if _, err := utils.Run(cmd); err != nil { + return "", err + } + + cmd = exec.Command("kubectl", "wait", "pod/"+podName, + "-n", namespace, + "--for", "jsonpath={.status.phase}=Succeeded", + "--timeout", "30s", + ) + if _, err := utils.Run(cmd); err != nil { + return "", err + } + + out, err := utils.Run(exec.Command("kubectl", "logs", podName, "-n", namespace)) + return string(out), err +} + var _ = Describe("controller", Ordered, func() { BeforeAll(func() { By("installing prometheus operator") @@ -119,4 +165,86 @@ var _ = Describe("controller", Ordered, func() { }) }) + + Context("APIKey lifecycle", func() { + AfterAll(func() { + By("removing the api key sample") + cmd := exec.Command("kubectl", "delete", "-n", namespace, "-f", apiKeySampleFile, "--ignore-not-found") + _, _ = utils.Run(cmd) + + By("removing the typesense cluster sample") + cmd = exec.Command("kubectl", "delete", "-n", namespace, "-f", clusterSampleFile, "--ignore-not-found") + _, _ = utils.Run(cmd) + }) + + It("should stand up a real Typesense cluster and issue a correctly scoped api key", func() { + By("applying the typesense cluster sample") + cmd := exec.Command("kubectl", "apply", "-n", namespace, "-f", clusterSampleFile) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for the typesense cluster to reach quorum") + cmd = exec.Command("kubectl", "wait", fmt.Sprintf("typesensecluster/%s", clusterName), + "-n", namespace, + "--for", "condition=Ready", + "--timeout", "8m", + ) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("applying the api key sample") + cmd = exec.Command("kubectl", "apply", "-n", namespace, "-f", apiKeySampleFile) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for the api key to be reconciled") + cmd = exec.Command("kubectl", "wait", fmt.Sprintf("typesenseapikey/%s", apiKeyName), + "-n", namespace, + "--for", "condition=Ready", + "--timeout", "2m", + ) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("fetching the secret holding the generated api key") + cmd = exec.Command("kubectl", "get", fmt.Sprintf("typesenseapikey/%s", apiKeyName), + "-n", namespace, + "-o", "jsonpath={.status.secretRef.name}", + ) + secretNameOutput, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + secretName := strings.TrimSpace(string(secretNameOutput)) + Expect(secretName).NotTo(BeEmpty()) + + cmd = exec.Command("kubectl", "get", "secret", secretName, + "-n", namespace, + "-o", "jsonpath={.data.value}", + ) + encodedKeyOutput, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + decodedKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encodedKeyOutput))) + Expect(err).NotTo(HaveOccurred()) + apiKeyValue := string(decodedKey) + Expect(apiKeyValue).NotTo(BeEmpty()) + + By("verifying the api key authenticates against the real typesense cluster") + searchUrl := fmt.Sprintf("http://%s:8108/collections/nonexistent-collection/documents/search?q=*&query_by=name", clusterServiceFQDN) + searchScript := fmt.Sprintf("echo STATUS:$(curl -s -o /dev/null -w '%%{http_code}' -H 'X-TYPESENSE-API-KEY: %s' '%s')", + apiKeyValue, searchUrl) + searchOutput, err := runCurlPod("tsapikey-verify-search", searchScript) + Expect(err).NotTo(HaveOccurred()) + Expect(searchOutput).To(ContainSubstring("STATUS:404"), + "expected the generated key to authenticate (404 collection-not-found), not be rejected") + + By("verifying the api key is scoped to documents:search only") + collectionsUrl := fmt.Sprintf("http://%s:8108/collections", clusterServiceFQDN) + scopeScript := fmt.Sprintf("echo STATUS:$(curl -s -o /dev/null -w '%%{http_code}' -X POST -H 'X-TYPESENSE-API-KEY: %s' -H 'Content-Type: application/json' -d '{\"name\":\"should-not-be-created\",\"fields\":[]}' '%s')", + apiKeyValue, collectionsUrl) + scopeOutput, err := runCurlPod("tsapikey-verify-scope", scopeScript) + Expect(err).NotTo(HaveOccurred()) + Expect(scopeOutput).To(ContainSubstring("STATUS:401"), + "expected the search-only key to be rejected for collections:create") + }) + }) }) From 6b038b808f2246a694659779e36a16d8e1fbcf68 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 14:56:11 -0400 Subject: [PATCH 10/19] docs: document TypesenseApiKey in README and CLAUDE.md README gets a user-facing "Issuing Scoped API Keys" section (example CR, generated Secret naming, rotation/drift behavior) matching the existing TypesenseCluster examples' style. CLAUDE.md's Architecture section only described TypesenseClusterReconciler; adds a matching section for TypesenseApiKeyReconciler (create/rotate/drift-check/ delete against the Typesense /keys API) so future sessions have the same grounding for the second controller that they do for the first. --- CLAUDE.md | 17 ++++++++++++++--- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0855c2..722b3a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -TyKO (Typesense Kubernetes Operator) — a Kubernetes operator, built with Operator SDK / kubebuilder (go.kubebuilder.io/v4), that manages the full lifecycle of highly-available Typesense clusters via a single CRD (`TypesenseCluster`, group `ts.opentelekomcloud.com/v1alpha1`). It automates ConfigMaps, Secrets, PVCs, StatefulSets, Services, Ingress/HTTPRoute, metrics scrapers, and — most notably — Raft quorum discovery/recovery without sidecars. +TyKO (Typesense Kubernetes Operator) — a Kubernetes operator, built with Operator SDK / kubebuilder (go.kubebuilder.io/v4), that manages the full lifecycle of highly-available Typesense clusters via two CRDs (group `ts.opentelekomcloud.com/v1alpha1`): `TypesenseCluster` and `TypesenseApiKey`. It automates ConfigMaps, Secrets, PVCs, StatefulSets, Services, Ingress/HTTPRoute, metrics scrapers, Typesense API key issuance/rotation, and — most notably — Raft quorum discovery/recovery without sidecars. ## Common commands @@ -64,9 +64,20 @@ After the StatefulSet phase, the controller distinguishes two actions based on w Only once the ConfigMap has settled does the controller call `ReconcileQuorum` and fold its `ConditionQuorum` result into the CR's status/events (`QuorumNeedsAttention*` conditions surface as Warning events requiring manual intervention — lagging writes or out-of-memory/disk; anything else not-ready is retried). +### Second controller: TypesenseApiKey + +`TypesenseApiKeyReconciler` (`internal/controller/typesenseapikey_controller.go`) drives the `TypesenseApiKey` CRD, independently of `TypesenseClusterReconciler`. It talks directly to a target `TypesenseCluster`'s Typesense `/keys` HTTP API (`typesenseapikey_client.go`, resolved via the `%s-svc..svc.cluster.local` Service DNS name and the cluster's admin key Secret) rather than reconciling Kubernetes sub-resources: + +- **Create** (`status.keyId == nil`): calls `POST /keys` with the spec's `actions`/`collections`/`description`/`expiresAt`/`value`, then writes the plaintext value to a Secret named `-typesense-key` (`typesenseapikey_secret.go`) and records `status.keyId`/`status.valuePrefix`/`status.observedGeneration`/`status.secretRef`. +- **Rotate** (`key.Generation != status.observedGeneration`, i.e. spec changed): Typesense keys are immutable, so this deletes the old remote key by id and creates a new one (same Secret name, so consumers don't need to change references). +- **Drift check** (steady state, every `apiKeyReconcileRequeuePeriod` = 5 min): re-fetches the remote key; a key deleted out-of-band is recreated, a key edited out-of-band is rotated. +- **Delete**: finalizer-guarded (`ApiKeyFinalizer`); deletes the remote Typesense key before removing the finalizer. + +Condition type is `Ready` (`typesenseapikey_condition_types.go`), same convention as `TypesenseCluster`, so `kubectl wait --for=condition=Ready` works uniformly on both CRDs. + ### API types layout (`api/v1alpha1/`) -`typesensecluster_types.go` holds the root `TypesenseClusterSpec`/`Status`; the sub-structs for each concern live in their own `typesensecluster_types_*.go` files (`_storage`, `_service`, `_ingress`, `_httproute`, `_scraper`, `_metrics`, `_healthcheck`, `_securitycontexts`), with helper methods in `typesensecluster_types_helpers.go`. `zz_generated.deepcopy.go` is generated — never hand-edit it; run `make generate` instead. +`typesensecluster_types.go` holds the root `TypesenseClusterSpec`/`Status`; the sub-structs for each concern live in their own `typesensecluster_types_*.go` files (`_storage`, `_service`, `_ingress`, `_httproute`, `_scraper`, `_metrics`, `_healthcheck`, `_securitycontexts`), with helper methods in `typesensecluster_types_helpers.go`. `typesenseapikey_types.go` holds `TypesenseApiKeySpec`/`Status` for the second CRD. `zz_generated.deepcopy.go` is generated — never hand-edit it; run `make generate` instead. ### Config entry point @@ -74,7 +85,7 @@ Only once the ConfigMap has settled does the controller call `ReconcileQuorum` a ## Making changes to the CRD -Any change to `api/v1alpha1/typesensecluster_types*.go` (new field, changed `+kubebuilder:` marker, etc.) requires `make manifests generate` before building/testing — this regenerates CRD YAML under `config/crd/` and `zz_generated.deepcopy.go`. The Helm chart under `charts/typesense-operator` is produced from the kustomize output via `make helmify` and should be regenerated alongside CRD/manifest changes, not edited by hand. +Any change to `api/v1alpha1/typesensecluster_types*.go` or `api/v1alpha1/typesenseapikey_types.go` (new field, changed `+kubebuilder:` marker, etc.) requires `make manifests generate` before building/testing — this regenerates CRD YAML under `config/crd/` and `zz_generated.deepcopy.go`. The Helm chart under `charts/typesense-operator` is produced from the kustomize output via `make helmify` and should be regenerated alongside CRD/manifest changes, not edited by hand. ## golangci-lint notes diff --git a/README.md b/README.md index a584a66..6bd437f 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,44 @@ spec: You can find more examples and analytical installation instructions in the [Installation](https://akyriako.github.io/typesense-operator-docs/docs/installation/) and [Configuration](https://akyriako.github.io/typesense-operator-docs/docs/crds) guides. +## 🔑 Issuing Scoped API Keys + +Beyond provisioning the cluster itself, TyKO can issue and manage Typesense API keys declaratively +via the `TypesenseApiKey` CRD, so scoped keys for your applications (e.g. a search-only key for a +public frontend) live in Git next to the cluster they belong to, instead of being generated by hand +and passed around out-of-band. + +```yaml +apiVersion: ts.opentelekomcloud.com/v1alpha1 +kind: TypesenseApiKey +metadata: + name: search-only-key +spec: + clusterRef: + name: ts-kind + description: read-only key for the search frontend + actions: + - documents:search + collections: + - "*" +``` + +The operator creates the key against the referenced cluster's Typesense API using its admin key, +writes the plaintext value to a Secret named `-typesense-key`, and records the key's Typesense +id and status in `.status`: + +```bash +kubectl get typesenseapikey search-only-key +# NAME CLUSTER KEY ID PHASE READY +# search-only-key ts-kind 1 Ready True +``` + +Editing `spec` (actions, collections, description, expiry) rotates the key: the old Typesense key is +deleted and a new one is created and written to the same Secret, so consumers keep referencing the +same Secret name across a rotation. The key is also re-checked periodically for drift (e.g. deleted +or edited directly through the Typesense API) and healed automatically. Deleting the +`TypesenseApiKey` CR deletes the corresponding key from Typesense. + ## 📚 Documentation - [Getting Started](https://akyriako.github.io/typesense-operator-docs/docs/getting-started) From e15eaf89ce1d445aa1e7bbf6a0818b55e5e2ce4d Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 14:58:45 -0400 Subject: [PATCH 11/19] chore: bump version to 0.5.0 for the TypesenseApiKey feature release New backwards-compatible functionality (the TypesenseApiKey CRD) warrants a MINOR version bump per semver. Updates Chart.yaml (version + appVersion), Makefile's IMG_TAG, and the matching image tags in values.yaml and config/manager/kustomization.yaml so the default image reference stays consistent everywhere. --- Makefile | 2 +- charts/typesense-operator/Chart.yaml | 4 ++-- charts/typesense-operator/values.yaml | 2 +- config/manager/kustomization.yaml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 03e115f..734bf89 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ OPERATOR_SDK_VERSION ?= v1.39.0 # Image URL to use all building/pushing image targets DOCKER_HUB_NAME ?= ghcr.io/interworks#$(shell docker info | sed '/Username:/!d;s/.* //') IMG_NAME ?= typesense-operator -IMG_TAG ?= 0.4.1 +IMG_TAG ?= 0.5.0 IMG ?= $(DOCKER_HUB_NAME)/$(IMG_NAME):$(IMG_TAG) # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. diff --git a/charts/typesense-operator/Chart.yaml b/charts/typesense-operator/Chart.yaml index e06436c..c646349 100644 --- a/charts/typesense-operator/Chart.yaml +++ b/charts/typesense-operator/Chart.yaml @@ -14,12 +14,12 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.4.1 +version: 0.5.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.4.1" +appVersion: "0.5.0" maintainers: - name: Kyriakos Akriotis diff --git a/charts/typesense-operator/values.yaml b/charts/typesense-operator/values.yaml index 35a8211..94f3c9c 100644 --- a/charts/typesense-operator/values.yaml +++ b/charts/typesense-operator/values.yaml @@ -12,7 +12,7 @@ controllerManager: - ALL image: repository: ghcr.io/interworks/typesense-operator - tag: 0.4.1 + tag: 0.5.0 imagePullPolicy: IfNotPresent resources: limits: diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index a11faf3..217daf5 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: ghcr.io/interworks/typesense-operator - newTag: 0.4.1 + newTag: 0.5.0 From cc7bfae34ecd2ec9c6bb58a184059c61f41976aa Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 15:03:48 -0400 Subject: [PATCH 12/19] ci: run lint and test workflows automatically on push/PR Both were workflow_dispatch-only, so nothing ran automatically against pushes or pull requests. Adds push (main) and pull_request triggers to lint.yml and test.yml, keeping workflow_dispatch for manual runs. test-e2e.yml and releases.yaml are left manual for now. --- .github/workflows/lint.yml | 4 ++++ .github/workflows/test.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8e2baf3..1afa0f1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,10 @@ name: Lint on: + push: + branches: + - main + pull_request: workflow_dispatch: jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7cbc3a5..92da8d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,10 @@ name: Tests on: + push: + branches: + - main + pull_request: workflow_dispatch: jobs: From 5d13a3265bc849b67c9efa1e67e65556a5752bff Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 15:05:11 -0400 Subject: [PATCH 13/19] chore: empty push test trigger From c33e1a8566b505a2722939d32e0c9540e6783d77 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 15:10:18 -0400 Subject: [PATCH 14/19] fix: wrap long lines in e2e test to satisfy lll Surfaced by finally running the lint workflow in CI (it was previously workflow_dispatch-only and had never actually run against this code). --- test/e2e/e2e_test.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 3c73ec5..a797ef1 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -229,9 +229,14 @@ var _ = Describe("controller", Ordered, func() { Expect(apiKeyValue).NotTo(BeEmpty()) By("verifying the api key authenticates against the real typesense cluster") - searchUrl := fmt.Sprintf("http://%s:8108/collections/nonexistent-collection/documents/search?q=*&query_by=name", clusterServiceFQDN) - searchScript := fmt.Sprintf("echo STATUS:$(curl -s -o /dev/null -w '%%{http_code}' -H 'X-TYPESENSE-API-KEY: %s' '%s')", - apiKeyValue, searchUrl) + searchUrl := fmt.Sprintf( + "http://%s:8108/collections/nonexistent-collection/documents/search?q=*&query_by=name", + clusterServiceFQDN, + ) + searchScript := fmt.Sprintf( + "echo STATUS:$(curl -s -o /dev/null -w '%%{http_code}' -H 'X-TYPESENSE-API-KEY: %s' '%s')", + apiKeyValue, searchUrl, + ) searchOutput, err := runCurlPod("tsapikey-verify-search", searchScript) Expect(err).NotTo(HaveOccurred()) Expect(searchOutput).To(ContainSubstring("STATUS:404"), @@ -239,8 +244,11 @@ var _ = Describe("controller", Ordered, func() { By("verifying the api key is scoped to documents:search only") collectionsUrl := fmt.Sprintf("http://%s:8108/collections", clusterServiceFQDN) - scopeScript := fmt.Sprintf("echo STATUS:$(curl -s -o /dev/null -w '%%{http_code}' -X POST -H 'X-TYPESENSE-API-KEY: %s' -H 'Content-Type: application/json' -d '{\"name\":\"should-not-be-created\",\"fields\":[]}' '%s')", - apiKeyValue, collectionsUrl) + scopeScript := fmt.Sprintf( + "echo STATUS:$(curl -s -o /dev/null -w '%%{http_code}' -X POST -H 'X-TYPESENSE-API-KEY: %s' "+ + "-H 'Content-Type: application/json' -d '{\"name\":\"should-not-be-created\",\"fields\":[]}' '%s')", + apiKeyValue, collectionsUrl, + ) scopeOutput, err := runCurlPod("tsapikey-verify-scope", scopeScript) Expect(err).NotTo(HaveOccurred()) Expect(scopeOutput).To(ContainSubstring("STATUS:401"), From ee75d8a3d91ff982dddf46b077a1fe436565e77f Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Fri, 28 Aug 2026 15:52:28 -0400 Subject: [PATCH 15/19] fix: clean up pre-existing golangci-lint findings Fixes all findings from issue #5 (42 originally reported, plus additional ones surfaced by the golangci-lint 2.13.1 pinned in mise.toml): gocyclo refactors of the four over-complexity reconcile functions, unused declarations, goconst duplication, staticcheck/revive/misspell/nakedret/ errcheck/unparam/prealloc/unconvert cleanups, and dot-import removal in test/utils. No behavior change; verified with make test and make test-e2e. --- .../typesensecluster_types_helpers.go | 2 +- .../typesensecluster_types_httproute.go | 8 +- cmd/main.go | 8 +- .../controller/typesenseapikey_controller.go | 5 +- .../typesenseapikey_controller_test.go | 24 +- internal/controller/typesenseapikey_secret.go | 6 +- .../controller/typesensecluster_configmap.go | 27 +- .../controller/typesensecluster_constants.go | 2 +- .../controller/typesensecluster_controller.go | 151 +++++----- .../typesensecluster_controller_test.go | 4 +- .../controller/typesensecluster_httproute.go | 185 ++++++------ .../controller/typesensecluster_ingress.go | 284 ++++++++++-------- .../controller/typesensecluster_podmonitor.go | 4 +- .../controller/typesensecluster_quorum.go | 158 +++++----- .../typesensecluster_quorum_helpers.go | 54 +--- .../typesensecluster_quorum_types.go | 3 +- .../controller/typesensecluster_scraper.go | 6 +- .../controller/typesensecluster_secret.go | 1 + .../controller/typesensecluster_services.go | 29 +- .../typesensecluster_statefulset.go | 48 +-- .../typesensecluster_statefulset_hash.go | 14 +- internal/controller/utils.go | 73 ++--- test/utils/utils.go | 10 +- 23 files changed, 566 insertions(+), 540 deletions(-) diff --git a/api/v1alpha1/typesensecluster_types_helpers.go b/api/v1alpha1/typesensecluster_types_helpers.go index 43f23f6..cadf8c6 100644 --- a/api/v1alpha1/typesensecluster_types_helpers.go +++ b/api/v1alpha1/typesensecluster_types_helpers.go @@ -45,7 +45,7 @@ func (s *TypesenseClusterSpec) GetCorsDomains() string { } func (s *TypesenseClusterSpec) GetTopologySpreadConstraints(labels map[string]string) []corev1.TopologySpreadConstraint { - tscs := make([]corev1.TopologySpreadConstraint, 0) + tscs := make([]corev1.TopologySpreadConstraint, 0, len(s.TopologySpreadConstraints)) for _, tsc := range s.TopologySpreadConstraints { if tsc.LabelSelector == nil { diff --git a/api/v1alpha1/typesensecluster_types_httproute.go b/api/v1alpha1/typesensecluster_types_httproute.go index d1a45f4..ae47beb 100644 --- a/api/v1alpha1/typesensecluster_types_httproute.go +++ b/api/v1alpha1/typesensecluster_types_httproute.go @@ -27,10 +27,10 @@ type HttpRouteSpec struct { // +kubebuilder:validation:Enum=Exact;PathPrefix;ImplementationSpecific PathType *gatewayv1.PathMatchType `json:"pathType,omitempty"` - //// +optional - //// +kubebuilder:default=false - //// +kubebuilder:validation:Type=boolean - //UseReverseProxy *bool `json:"useReverseProxy,omitempty"` + // // +optional + // // +kubebuilder:default=false + // // +kubebuilder:validation:Type=boolean + // UseReverseProxy *bool `json:"useReverseProxy,omitempty"` // +optional Labels map[string]string `json:"labels,omitempty"` diff --git a/cmd/main.go b/cmd/main.go index 64cadb3..307c817 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -232,10 +232,10 @@ func main() { os.Exit(1) } - //discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeConfig) - //if err != nil { - // setupLog.Error(err, "unable to create discovery client") - //} + // discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeConfig) + // if err != nil { + // setupLog.Error(err, "unable to create discovery client") + // } if err = (&controller.TypesenseClusterReconciler{ Client: mgr.GetClient(), diff --git a/internal/controller/typesenseapikey_controller.go b/internal/controller/typesenseapikey_controller.go index 27c71b4..0c94531 100644 --- a/internal/controller/typesenseapikey_controller.go +++ b/internal/controller/typesenseapikey_controller.go @@ -76,7 +76,10 @@ func (r *TypesenseApiKeyReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err := r.Update(ctx, &key); err != nil { return ctrl.Result{}, err } - return ctrl.Result{Requeue: true}, nil + // Requeue immediately (not RequeueAfter apiKeyReconcileRequeuePeriod like the rest of this + // function) so the finalizer add is picked straight back up instead of waiting out the + // steady-state period. + return ctrl.Result{Requeue: true}, nil //nolint:staticcheck // SA1019: RequeueAfter has no immediate-requeue equivalent } if err := r.initConditions(ctx, &key); err != nil { diff --git a/internal/controller/typesenseapikey_controller_test.go b/internal/controller/typesenseapikey_controller_test.go index 2c5ebc7..3c88fe3 100644 --- a/internal/controller/typesenseapikey_controller_test.go +++ b/internal/controller/typesenseapikey_controller_test.go @@ -40,6 +40,12 @@ import ( tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" ) +const ( + testTypesenseImage = "typesense/typesense:27.1" + testStorageClassName = "standard" + testDocumentsSearchAction = "documents:search" +) + // fakeKeysServer is a minimal in-memory stand-in for the Typesense /keys REST API, used so the // TypesenseApiKeyReconciler tests can exercise real HTTP request/response handling without a // live Typesense cluster. It keeps created keys in memory so GET can reflect drift (or a @@ -176,9 +182,9 @@ var _ = Describe("TypesenseApiKey Controller", func() { Namespace: namespace, }, Spec: tsv1alpha1.TypesenseClusterSpec{ - Image: "typesense/typesense:27.1", + Image: testTypesenseImage, Storage: &tsv1alpha1.StorageSpec{ - StorageClassName: "standard", + StorageClassName: testStorageClassName, }, }, } @@ -218,7 +224,7 @@ var _ = Describe("TypesenseApiKey Controller", func() { Spec: tsv1alpha1.TypesenseApiKeySpec{ ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, Description: "test key", - Actions: []string{"documents:search"}, + Actions: []string{testDocumentsSearchAction}, Collections: []string{"*"}, }, } @@ -260,7 +266,7 @@ var _ = Describe("TypesenseApiKey Controller", func() { Spec: tsv1alpha1.TypesenseApiKeySpec{ ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, Description: "rotate key", - Actions: []string{"documents:search"}, + Actions: []string{testDocumentsSearchAction}, Collections: []string{"*"}, }, } @@ -307,7 +313,7 @@ var _ = Describe("TypesenseApiKey Controller", func() { Spec: tsv1alpha1.TypesenseApiKeySpec{ ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, Description: "drift key", - Actions: []string{"documents:search"}, + Actions: []string{testDocumentsSearchAction}, Collections: []string{"*"}, }, } @@ -345,7 +351,7 @@ var _ = Describe("TypesenseApiKey Controller", func() { Spec: tsv1alpha1.TypesenseApiKeySpec{ ClusterRef: tsv1alpha1.TypesenseClusterReference{Name: clusterName}, Description: "drift key", - Actions: []string{"documents:search"}, + Actions: []string{testDocumentsSearchAction}, Collections: []string{"*"}, }, } @@ -397,9 +403,9 @@ var _ = Describe("TypesenseApiKey Controller", func() { Namespace: otherNamespace.Name, }, Spec: tsv1alpha1.TypesenseClusterSpec{ - Image: "typesense/typesense:27.1", + Image: testTypesenseImage, Storage: &tsv1alpha1.StorageSpec{ - StorageClassName: "standard", + StorageClassName: testStorageClassName, }, }, } @@ -430,7 +436,7 @@ var _ = Describe("TypesenseApiKey Controller", func() { Namespace: otherNamespace.Name, }, Description: "cross namespace key", - Actions: []string{"documents:search"}, + Actions: []string{testDocumentsSearchAction}, Collections: []string{"*"}, }, } diff --git a/internal/controller/typesenseapikey_secret.go b/internal/controller/typesenseapikey_secret.go index 8d95c51..ee9c516 100644 --- a/internal/controller/typesenseapikey_secret.go +++ b/internal/controller/typesenseapikey_secret.go @@ -31,9 +31,9 @@ func (r *TypesenseApiKeyReconciler) ReconcileSecret(ctx context.Context, key *ts Name: secretObjectKey.Name, Namespace: secretObjectKey.Namespace, Labels: map[string]string{ - "app.kubernetes.io/managed-by": "typesense-operator", - "app.kubernetes.io/name": "typesense-api-key", - "app.kubernetes.io/instance": key.Name, + labelManagedBy: managedByValue, + labelName: "typesense-api-key", + labelInstance: key.Name, }, }, Type: v1.SecretTypeOpaque, diff --git a/internal/controller/typesensecluster_configmap.go b/internal/controller/typesensecluster_configmap.go index 0c4abdf..2d2658c 100644 --- a/internal/controller/typesensecluster_configmap.go +++ b/internal/controller/typesensecluster_configmap.go @@ -52,7 +52,7 @@ func (r *TypesenseClusterReconciler) ReconcileConfigMap(ctx context.Context, ts return nil, nil } - _, _, updated, err := r.updateConfigMap(ctx, &ts, cm, nil, false) + _, updated, err := r.updateConfigMap(ctx, &ts, cm, nil, false) if err != nil { return ptr.To[bool](false), err } @@ -89,7 +89,7 @@ func (r *TypesenseClusterReconciler) createConfigMap(ctx context.Context, key cl return cm, nil } -func (r *TypesenseClusterReconciler) updateConfigMap(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, cm *v1.ConfigMap, replicas *int32, resizeOp bool) (*v1.ConfigMap, int, bool, error) { +func (r *TypesenseClusterReconciler) updateConfigMap(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, cm *v1.ConfigMap, replicas *int32, resizeOp bool) (int, bool, error) { stsName := fmt.Sprintf(ClusterStatefulSet, ts.Name) stsObjectKey := client.ObjectKey{ Name: stsName, @@ -101,13 +101,13 @@ func (r *TypesenseClusterReconciler) updateConfigMap(ctx context.Context, ts *ts if apierrors.IsNotFound(err) { err := r.deleteConfigMap(ctx, cm) if err != nil { - return nil, 0, false, err + return 0, false, err } } else { r.logger.Error(err, fmt.Sprintf("unable to fetch statefulset: %s", stsName)) } - return nil, 0, false, err + return 0, false, err } if replicas == nil { @@ -116,17 +116,17 @@ func (r *TypesenseClusterReconciler) updateConfigMap(ctx context.Context, ts *ts nodes, err := r.getNodes(ctx, ts, *replicas, false) if err != nil { - return nil, 0, false, err + return 0, false, err } fallback, err := r.getNodes(ctx, ts, *replicas, true) if err != nil { - return nil, 0, false, err + return 0, false, err } availableNodes := len(nodes) if availableNodes == 0 { r.logger.V(debugLevel).Info("empty quorum configuration") - return nil, 0, false, fmt.Errorf("empty quorum configuration") + return 0, false, fmt.Errorf("empty quorum configuration") } desired := cm.DeepCopy() @@ -151,12 +151,12 @@ func (r *TypesenseClusterReconciler) updateConfigMap(ctx context.Context, ts *ts err := r.Update(ctx, desired) if err != nil { r.logger.Error(err, "updating quorum configuration failed") - return nil, 0, false, err + return 0, false, err } updated = true } - return desired, availableNodes, updated, nil + return availableNodes, updated, nil } func (r *TypesenseClusterReconciler) deleteConfigMap(ctx context.Context, cm *v1.ConfigMap) error { @@ -176,7 +176,7 @@ func (r *TypesenseClusterReconciler) forcePodsConfigMapUpdate(ctx context.Contex labelSelector := labels.SelectorFromSet(labelMap) var podList v1.PodList - if err := r.Client.List(ctx, &podList, + if err := r.List(ctx, &podList, client.InNamespace(ts.Namespace), client.MatchingLabelsSelector{Selector: labelSelector}, ); err != nil { @@ -270,7 +270,6 @@ func (r *TypesenseClusterReconciler) getNodes(ctx context.Context, ts *tsv1alpha switch cs.State.Waiting.Reason { case "ContainerCreating", "ErrImagePull", "ImagePullBackOff": markAsScheduled = true - break } } @@ -309,7 +308,7 @@ func (r *TypesenseClusterReconciler) getNodes(ctx context.Context, ts *tsv1alpha for _, e := range s.Endpoints { if len(e.Addresses) > 0 { addr := e.Addresses[0] - //r.logger.V(debugLevel).Info("discovered slice endpoint", "slice", s.Name, "endpoint", e.Hostname, "address", addr) + // r.logger.V(debugLevel).Info("discovered slice endpoint", "slice", s.Name, "endpoint", e.Hostname, "address", addr) nodes = append(nodes, fmt.Sprintf("%s:%d:%d", addr, ts.Spec.PeeringPort, ts.Spec.ApiPort)) } } @@ -325,7 +324,7 @@ func (r *TypesenseClusterReconciler) getEndpointSlicesForStatefulSet(ctx context // 1) List EndpointSlices for headless Service var sliceList discoveryv1.EndpointSliceList - if err := r.Client.List(ctx, &sliceList, + if err := r.List(ctx, &sliceList, client.InNamespace(namespace), client.MatchingLabels{discoveryv1.LabelServiceName: svcName}, ); err != nil { @@ -335,7 +334,7 @@ func (r *TypesenseClusterReconciler) getEndpointSlicesForStatefulSet(ctx context // 2) Build a set of “live” Pod IPs for this StatefulSet selector := labels.SelectorFromSet(sts.Spec.Selector.MatchLabels) var podList v1.PodList - if err := r.Client.List(ctx, &podList, + if err := r.List(ctx, &podList, client.InNamespace(namespace), client.MatchingLabelsSelector{Selector: selector}, ); err != nil { diff --git a/internal/controller/typesensecluster_constants.go b/internal/controller/typesensecluster_constants.go index 8bb375c..dc5ade8 100644 --- a/internal/controller/typesensecluster_constants.go +++ b/internal/controller/typesensecluster_constants.go @@ -19,7 +19,7 @@ const ( ClusterHttpRoute = "%s-%s" ClusterHttpRouteReferenceGrant = "%s-%s-reference-grant" - //TODO Remove them future version 0.2.15 + // TODO Remove them future version 0.2.15 ClusterPrometheusExporterAppLabel = "%s-prometheus-exporter" ClusterPrometheusExporterDeployment = "%s-prometheus-exporter" diff --git a/internal/controller/typesensecluster_controller.go b/internal/controller/typesensecluster_controller.go index 46762c5..a9005ed 100644 --- a/internal/controller/typesensecluster_controller.go +++ b/internal/controller/typesensecluster_controller.go @@ -114,6 +114,66 @@ const ( // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.18.4/pkg/reconcile +// failStep records a not-ready condition for a failed reconciliation step, folding in any error +// encountered while recording the condition itself, so callers can return a single error value. +func (r *TypesenseClusterReconciler) failStep(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, reason string, err error) error { + cerr := r.setConditionNotReady(ctx, ts, reason, err) + if cerr != nil { + err = errors.Wrap(err, cerr.Error()) + } + return err +} + +// reportQuorumCondition records the TypesenseCluster's condition based on the quorum's reported +// state, raising a warning event on anything but ClusterStatusOK. +func (r *TypesenseClusterReconciler) reportQuorumCondition(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, condition ConditionQuorum, quorumErr error) error { + if strings.Contains(string(condition), "QuorumNeedsAttention") { + eram := "cluster needs manual administrative attention: " + + if condition == ConditionReasonQuorumNeedsAttentionClusterIsLagging { + eram += "queued_writes > healthyWriteLagThreshold" + } + + if condition == ConditionReasonQuorumNeedsAttentionMemoryOrDiskIssue { + eram += "out of memory or disk" + } + + erram := errors.New(eram) + if cerr := r.setConditionNotReady(ctx, ts, string(condition), erram); cerr != nil { + return cerr + } + r.Recorder.Eventf(ts, "Warning", string(condition), toTitle(erram.Error())) + + return nil + } + + if condition != ConditionReasonQuorumReady { + err := quorumErr + if err == nil { + err = errors.New("quorum is not ready") + } + if cerr := r.setConditionNotReady(ctx, ts, string(condition), err); cerr != nil { + return cerr + } + + r.Recorder.Eventf(ts, "Warning", string(condition), toTitle(err.Error())) + + return nil + } + + report := ts.Status.Conditions[0].Status != metav1.ConditionTrue + + if cerr := r.setConditionReady(ctx, ts, string(condition)); cerr != nil { + return cerr + } + + if report { + r.Recorder.Eventf(ts, "Normal", string(condition), toTitle("quorum is ready")) + } + + return nil +} + func (r *TypesenseClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { r.logger = log.Log.WithValues("namespace", req.Namespace, "cluster", req.Name) @@ -132,81 +192,49 @@ func (r *TypesenseClusterReconciler) Reconcile(ctx context.Context, req ctrl.Req // Update strategy: Admin Secret is Immutable, will not be updated on any future change secret, err := r.ReconcileSecret(ctx, ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonSecretNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonSecretNotReady, err) } // Update strategy: Update the existing object, if changes are identified in the desired.Data["nodes"] configMapUpdated, err := r.ReconcileConfigMap(ctx, ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonConfigMapNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonConfigMapNotReady, err) } // Update strategy: Update the existing objects, if changes are identified in api and peering ports err = r.ReconcileServices(ctx, ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonServicesNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonServicesNotReady, err) } // Update strategy: Update the existing objects, if changes are identified in api and peering ports err = r.ReconcileIngress(ctx, &ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonIngressNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonIngressNotReady, err) } // Update strategy: Update the existing objects, if changes are identified err = r.ReconcileHttpRoute(ctx, &ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonHttpRouteNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonHttpRouteNotReady, err) } // Update strategy: Drop the existing objects and recreate them, if changes are identified err = r.ReconcileScraper(ctx, ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonScrapersNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonScrapersNotReady, err) } // Update strategy: Update the Deployment if image changed. Drop the existing ServiceMonitor and recreate it, if changes are identified err = r.ReconcilePodMonitor(ctx, ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonMetricsExporterNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonMetricsExporterNotReady, err) } // Update strategy: Update the whole specs when changes are identified sts, _, err := r.ReconcileStatefulSet(ctx, &ts) if err != nil { - cerr := r.setConditionNotReady(ctx, &ts, ConditionReasonStatefulSetNotReady, err) - if cerr != nil { - err = errors.Wrap(err, cerr.Error()) - } - return ctrl.Result{}, err + return ctrl.Result{}, r.failStep(ctx, &ts, ConditionReasonStatefulSetNotReady, err) } terminationGracePeriodSeconds := *sts.Spec.Template.Spec.TerminationGracePeriodSeconds @@ -254,47 +282,8 @@ func (r *TypesenseClusterReconciler) Reconcile(ctx context.Context, req ctrl.Req r.logger.Error(err, "reconciling quorum health failed") } - if strings.Contains(string(condition), "QuorumNeedsAttention") { - eram := "cluster needs manual administrative attention: " - - if condition == ConditionReasonQuorumNeedsAttentionClusterIsLagging { - eram += "queued_writes > healthyWriteLagThreshold" - } - - if condition == ConditionReasonQuorumNeedsAttentionMemoryOrDiskIssue { - eram += "out of memory or disk" - } - - erram := errors.New(eram) - cerr := r.setConditionNotReady(ctx, &ts, string(condition), erram) - if cerr != nil { - return ctrl.Result{}, cerr - } - r.Recorder.Eventf(&ts, "Warning", string(condition), toTitle(erram.Error())) - - } else { - if condition != ConditionReasonQuorumReady { - if err == nil { - err = errors.New("quorum is not ready") - } - cerr := r.setConditionNotReady(ctx, &ts, string(condition), err) - if cerr != nil { - return ctrl.Result{}, cerr - } - - r.Recorder.Eventf(&ts, "Warning", string(condition), toTitle(err.Error())) - } else { - report := ts.Status.Conditions[0].Status != metav1.ConditionTrue - - cerr := r.setConditionReady(ctx, &ts, string(condition)) - if cerr != nil { - return ctrl.Result{}, cerr - } - - if report { - r.Recorder.Eventf(&ts, "Normal", string(condition), toTitle("quorum is ready")) - } - } + if cerr := r.reportQuorumCondition(ctx, &ts, condition, err); cerr != nil { + return ctrl.Result{}, cerr } cond = condition diff --git a/internal/controller/typesensecluster_controller_test.go b/internal/controller/typesensecluster_controller_test.go index e6b865c..37917d0 100644 --- a/internal/controller/typesensecluster_controller_test.go +++ b/internal/controller/typesensecluster_controller_test.go @@ -54,10 +54,10 @@ var _ = Describe("TypesenseCluster Controller", func() { Namespace: "default", }, Spec: tsv1alpha1.TypesenseClusterSpec{ - Image: "typesense/typesense:27.1", + Image: testTypesenseImage, Storage: &tsv1alpha1.StorageSpec{ Size: resource.MustParse("100Mi"), - StorageClassName: "standard", + StorageClassName: testStorageClassName, }, }, } diff --git a/internal/controller/typesensecluster_httproute.go b/internal/controller/typesensecluster_httproute.go index af9b6b7..7ae2701 100644 --- a/internal/controller/typesensecluster_httproute.go +++ b/internal/controller/typesensecluster_httproute.go @@ -68,100 +68,118 @@ func (r *TypesenseClusterReconciler) ReconcileHttpRoute(ctx context.Context, ts } if !httpRouteExists && hrt.Enabled { - r.logger.V(debugLevel).Info("creating http route", "http_route", httpRouteName) - - httpRoute, err = r.createHttpRoute(ctx, httpRouteObjectKey, hrt, ts) - if err != nil { - r.logger.Error(err, "creating http route failed", "http_route", httpRouteName) + if err := r.reconcileNewHttpRoute(ctx, httpRouteObjectKey, hrt, ts, httpRouteName); err != nil { return err } - - if *hrt.ReferenceGrant { - _, err := r.createReferenceGrant(ctx, hrt, ts) - if err != nil { - r.logger.Error(err, "creating reference grant failed", "http_route", httpRouteName) - return err - } - } } else { - if !hrt.Enabled { - referenceGrantsLabelSelector := labels.SelectorFromSet(map[string]string{ - "route": httpRoute.Name, - }) - - var referenceGrants gatewayv1beta1.ReferenceGrantList - if err := r.List(ctx, &referenceGrants, &client.ListOptions{ - LabelSelector: referenceGrantsLabelSelector, - }); err != nil { - gerr := fmt.Errorf("failed to list reference grants: %w", err) - r.logger.Error(gerr, "reconciling http routes failed") - return gerr - } - - for _, rg := range referenceGrants.Items { - err := r.deleteReferenceGrant(ctx, &rg) - if err != nil { - if !apierrors.IsNotFound(err) { - r.logger.Error(err, "deleting reference grant failed: %w", err) - } - } - } - - err = r.deleteHttpRoute(ctx, httpRoute) - if err != nil { - gerr := fmt.Errorf("deleting http route failed: %w", err) - r.logger.Error(gerr, "reconciling http routes failed") - return gerr - } + if err := r.reconcileExistingHttpRoute(ctx, httpRoute, hrt, ts, httpRouteName); err != nil { + return err } + } + } - lbls := r.getHttpRouteLabels(httpRoute, hrt, ts) - annotations := r.getHttpRouteAnnotations(httpRoute, ts) - - pRef := hrt.ParentRef - kind := gatewayv1.Kind("Gateway") - group := gatewayv1.Group(gatewayApiGroup) - parentRef := gatewayv1.ParentReference{ - Group: &group, - Kind: &kind, - Name: gatewayv1.ObjectName(pRef.Name), - Namespace: pRef.Namespace, - SectionName: pRef.SectionName, - } + return nil +} - hostnames := make([]gatewayv1.Hostname, 0, len(hrt.Hostnames)) - for _, h := range hrt.Hostnames { - hostnames = append(hostnames, gatewayv1.Hostname(h)) - } +func (r *TypesenseClusterReconciler) reconcileNewHttpRoute(ctx context.Context, httpRouteObjectKey client.ObjectKey, hrt tsv1alpha1.HttpRouteSpec, ts *tsv1alpha1.TypesenseCluster, httpRouteName string) error { + r.logger.V(debugLevel).Info("creating http route", "http_route", httpRouteName) - path := *httpRoute.Spec.Rules[0].Matches[0].Path.Value - pathType := httpRoute.Spec.Rules[0].Matches[0].Path.Type + _, err := r.createHttpRoute(ctx, httpRouteObjectKey, hrt, ts) + if err != nil { + r.logger.Error(err, "creating http route failed", "http_route", httpRouteName) + return err + } - if !apiequality.Semantic.DeepEqual(hostnames, httpRoute.Spec.Hostnames) || - !apiequality.Semantic.DeepEqual(hrt.Labels, lbls) || - !apiequality.Semantic.DeepEqual(hrt.Annotations, annotations) || - !apiequality.Semantic.DeepEqual(parentRef, httpRoute.Spec.ParentRefs[0]) || - hrt.Path != path || *hrt.PathType != *pathType { + if *hrt.ReferenceGrant { + err := r.createReferenceGrant(ctx, hrt, ts) + if err != nil { + r.logger.Error(err, "creating reference grant failed", "http_route", httpRouteName) + return err + } + } - r.logger.V(debugLevel).Info("updating http route", "http_route", httpRouteName) + return nil +} - httpRoute, err = r.updateHttpRoute(ctx, hrt, httpRoute, ts) - if err != nil { - r.logger.Error(err, "updating http route failed", "http_route", httpRouteName) - return err - } - } +func (r *TypesenseClusterReconciler) deleteDisabledHttpRoute(ctx context.Context, httpRoute *gatewayv1.HTTPRoute) error { + referenceGrantsLabelSelector := labels.SelectorFromSet(map[string]string{ + "route": httpRoute.Name, + }) - err := r.updateReferenceGrant(ctx, hrt, ts) - if err != nil { - return err + var referenceGrants gatewayv1beta1.ReferenceGrantList + if err := r.List(ctx, &referenceGrants, &client.ListOptions{ + LabelSelector: referenceGrantsLabelSelector, + }); err != nil { + gerr := fmt.Errorf("failed to list reference grants: %w", err) + r.logger.Error(gerr, "reconciling http routes failed") + return gerr + } + + for _, rg := range referenceGrants.Items { + err := r.deleteReferenceGrant(ctx, &rg) + if err != nil { + if !apierrors.IsNotFound(err) { + r.logger.Error(err, "deleting reference grant failed: %w", err) } } } + if err := r.deleteHttpRoute(ctx, httpRoute); err != nil { + gerr := fmt.Errorf("deleting http route failed: %w", err) + r.logger.Error(gerr, "reconciling http routes failed") + return gerr + } + return nil } +func (r *TypesenseClusterReconciler) reconcileExistingHttpRoute(ctx context.Context, httpRoute *gatewayv1.HTTPRoute, hrt tsv1alpha1.HttpRouteSpec, ts *tsv1alpha1.TypesenseCluster, httpRouteName string) error { + if !hrt.Enabled { + if err := r.deleteDisabledHttpRoute(ctx, httpRoute); err != nil { + return err + } + } + + lbls := r.getHttpRouteLabels(httpRoute, hrt, ts) + annotations := r.getHttpRouteAnnotations(httpRoute, ts) + + pRef := hrt.ParentRef + kind := gatewayv1.Kind("Gateway") + group := gatewayv1.Group(gatewayApiGroup) + parentRef := gatewayv1.ParentReference{ + Group: &group, + Kind: &kind, + Name: gatewayv1.ObjectName(pRef.Name), + Namespace: pRef.Namespace, + SectionName: pRef.SectionName, + } + + hostnames := make([]gatewayv1.Hostname, 0, len(hrt.Hostnames)) + for _, h := range hrt.Hostnames { + hostnames = append(hostnames, gatewayv1.Hostname(h)) + } + + path := *httpRoute.Spec.Rules[0].Matches[0].Path.Value + pathType := httpRoute.Spec.Rules[0].Matches[0].Path.Type + + if !apiequality.Semantic.DeepEqual(hostnames, httpRoute.Spec.Hostnames) || + !apiequality.Semantic.DeepEqual(hrt.Labels, lbls) || + !apiequality.Semantic.DeepEqual(hrt.Annotations, annotations) || + !apiequality.Semantic.DeepEqual(parentRef, httpRoute.Spec.ParentRefs[0]) || + hrt.Path != path || *hrt.PathType != *pathType { + + r.logger.V(debugLevel).Info("updating http route", "http_route", httpRouteName) + + _, err := r.updateHttpRoute(ctx, hrt, httpRoute, ts) + if err != nil { + r.logger.Error(err, "updating http route failed", "http_route", httpRouteName) + return err + } + } + + return r.updateReferenceGrant(ctx, hrt, ts) +} + func (r *TypesenseClusterReconciler) createHttpRoute(ctx context.Context, key client.ObjectKey, spec tsv1alpha1.HttpRouteSpec, ts *tsv1alpha1.TypesenseCluster) (*gatewayv1.HTTPRoute, error) { annotations := map[string]string{} if spec.Annotations != nil { @@ -280,7 +298,7 @@ func (r *TypesenseClusterReconciler) updateHttpRoute(ctx context.Context, spec t patch := client.MergeFrom(httpRoute.DeepCopy()) parentRef := r.getGatewayParentRef(spec, ts) - httpRoute.Spec.CommonRouteSpec.ParentRefs[0] = parentRef + httpRoute.Spec.ParentRefs[0] = parentRef hostnames := make([]gatewayv1.Hostname, 0, len(spec.Hostnames)) for _, h := range spec.Hostnames { @@ -342,7 +360,7 @@ func (r *TypesenseClusterReconciler) getGatewayParentRef(spec tsv1alpha1.HttpRou return parentRef } -func (r *TypesenseClusterReconciler) createReferenceGrant(ctx context.Context, spec tsv1alpha1.HttpRouteSpec, ts *tsv1alpha1.TypesenseCluster) (*gatewayv1beta1.ReferenceGrant, error) { +func (r *TypesenseClusterReconciler) createReferenceGrant(ctx context.Context, spec tsv1alpha1.HttpRouteSpec, ts *tsv1alpha1.TypesenseCluster) error { parentRefName := gatewayv1beta1.ObjectName(spec.ParentRef.Name) referenceGrant := &gatewayv1beta1.ReferenceGrant{ ObjectMeta: getReferenceGrantObjectMeta(ts, spec), @@ -369,12 +387,7 @@ func (r *TypesenseClusterReconciler) createReferenceGrant(ctx context.Context, s // have to be in the same namespace as Gateway, and cross-domain ownerships are // not allowed. - err := r.Create(ctx, referenceGrant) - if err != nil { - return nil, err - } - - return referenceGrant, nil + return r.Create(ctx, referenceGrant) } func (r *TypesenseClusterReconciler) deleteReferenceGrant(ctx context.Context, rg *gatewayv1beta1.ReferenceGrant) error { @@ -388,7 +401,7 @@ func (r *TypesenseClusterReconciler) deleteReferenceGrant(ctx context.Context, r func (r *TypesenseClusterReconciler) deleteOrphanedReferenceGrants(ctx context.Context) error { referenceGrantsLabelSelector := labels.SelectorFromSet(map[string]string{ - "app.kubernetes.io/managed-by": "typesense-operator", + labelManagedBy: managedByValue, }) var referenceGrants gatewayv1beta1.ReferenceGrantList @@ -461,7 +474,7 @@ func (r *TypesenseClusterReconciler) updateReferenceGrant(ctx context.Context, s } if cre { - _, err := r.createReferenceGrant(ctx, spec, ts) + err := r.createReferenceGrant(ctx, spec, ts) if err != nil { r.logger.Error(err, "creating reference grant failed", "http_route", spec.Name) return err diff --git a/internal/controller/typesensecluster_ingress.go b/internal/controller/typesensecluster_ingress.go index 0bdc663..0413fbb 100644 --- a/internal/controller/typesensecluster_ingress.go +++ b/internal/controller/typesensecluster_ingress.go @@ -57,7 +57,7 @@ const ( const clusterIssuerAnnotationKey = "cert-manager.io/cluster-issuer" -func (r *TypesenseClusterReconciler) ReconcileIngress(ctx context.Context, ts *tsv1alpha1.TypesenseCluster) (err error) { +func (r *TypesenseClusterReconciler) ReconcileIngress(ctx context.Context, ts *tsv1alpha1.TypesenseCluster) error { r.logger.V(debugLevel).Info("reconciling ingress") ingressName := fmt.Sprintf(ClusterReverseProxyIngress, ts.Name) @@ -84,38 +84,63 @@ func (r *TypesenseClusterReconciler) ReconcileIngress(ctx context.Context, ts *t return nil } + ig, err := r.reconcileIngressResource(ctx, ingressObjectKey, ts, ig, ingressExists) + if err != nil { + return err + } + + configMapUpdated, err := r.reconcileIngressConfigMap(ctx, ts, ig) + if err != nil { + return err + } + + if err := r.reconcileIngressDeployment(ctx, ts, ig, configMapUpdated); err != nil { + return err + } + + return r.reconcileIngressServiceResource(ctx, ts, ig) +} + +func (r *TypesenseClusterReconciler) reconcileIngressResource(ctx context.Context, key client.ObjectKey, ts *tsv1alpha1.TypesenseCluster, ig *networkingv1.Ingress, ingressExists bool) (*networkingv1.Ingress, error) { if !ingressExists { - r.logger.V(debugLevel).Info("creating ingress", "ingress", ingressObjectKey.Name) + r.logger.V(debugLevel).Info("creating ingress", "ingress", key.Name) - ig, err = r.createIngress(ctx, ingressObjectKey, ts) + created, err := r.createIngress(ctx, key, ts) if err != nil { - r.logger.Error(err, "creating ingress failed", "ingress", ingressObjectKey.Name) - return err + r.logger.Error(err, "creating ingress failed", "ingress", key.Name) + return nil, err } - } else { - lbls := r.getIngressLabels(ig, ts, ingressObjectKey) - anons := r.getIngressAnnotations(ig, ts) - - if ts.Spec.Ingress.Host != ig.Spec.Rules[0].Host || - (ts.Spec.Ingress.ClusterIssuer != nil && *ts.Spec.Ingress.ClusterIssuer != ig.Annotations[clusterIssuerAnnotationKey]) || - !apiequality.Semantic.DeepEqual(ts.Spec.Ingress.Labels, lbls) || - !apiequality.Semantic.DeepEqual(ts.Spec.Ingress.Annotations, anons) || - (ts.Spec.Ingress.TLSSecretName != nil && *ts.Spec.Ingress.TLSSecretName != ig.Spec.TLS[0].SecretName) || - ts.Spec.Ingress.IngressClassName != *ig.Spec.IngressClassName || - ts.Spec.Ingress.Path != ig.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].Path || - *ts.Spec.Ingress.PathType != *ig.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].PathType { - - r.logger.V(debugLevel).Info("updating ingress", "ingress", ingressObjectKey.Name) - - ig, err = r.updateIngress(ctx, *ig, ts) - if err != nil { - r.logger.Error(err, "updating ingress failed", "ingress", ingressObjectKey.Name) - return err - } + + return created, nil + } + + lbls := r.getIngressLabels(ig, ts, key) + anons := r.getIngressAnnotations(ig, ts) + + if ts.Spec.Ingress.Host != ig.Spec.Rules[0].Host || + (ts.Spec.Ingress.ClusterIssuer != nil && *ts.Spec.Ingress.ClusterIssuer != ig.Annotations[clusterIssuerAnnotationKey]) || + !apiequality.Semantic.DeepEqual(ts.Spec.Ingress.Labels, lbls) || + !apiequality.Semantic.DeepEqual(ts.Spec.Ingress.Annotations, anons) || + (ts.Spec.Ingress.TLSSecretName != nil && *ts.Spec.Ingress.TLSSecretName != ig.Spec.TLS[0].SecretName) || + ts.Spec.Ingress.IngressClassName != *ig.Spec.IngressClassName || + ts.Spec.Ingress.Path != ig.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].Path || + *ts.Spec.Ingress.PathType != *ig.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].PathType { + + r.logger.V(debugLevel).Info("updating ingress", "ingress", key.Name) + + updated, err := r.updateIngress(ctx, *ig, ts) + if err != nil { + r.logger.Error(err, "updating ingress failed", "ingress", key.Name) + return nil, err } + return updated, nil } + return ig, nil +} + +func (r *TypesenseClusterReconciler) reconcileIngressConfigMap(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, ig *networkingv1.Ingress) (bool, error) { configMapName := fmt.Sprintf(ClusterReverseProxyConfigMap, ts.Name) configMapExists := true configMapObjectKey := client.ObjectKey{Namespace: ts.Namespace, Name: configMapName} @@ -126,37 +151,100 @@ func (r *TypesenseClusterReconciler) ReconcileIngress(ctx context.Context, ts *t configMapExists = false } else { r.logger.Error(err, fmt.Sprintf("unable to fetch ingress config map: %s", configMapName)) - return err + return false, err } } - configMapUpdated := false if !configMapExists { r.logger.V(debugLevel).Info("creating ingress config map", "configmap", configMapObjectKey.Name) - _, err = r.createIngressConfigMap(ctx, configMapObjectKey, ts, ig) + _, err := r.createIngressConfigMap(ctx, configMapObjectKey, ts, ig) if err != nil { r.logger.Error(err, "creating ingress config map failed", "configmap", configMapObjectKey.Name) - return err - } - } else { - shouldUpdate, err := r.shouldUpdateIngressConfigMap(cm, ts) - if err != nil { - return err + return false, err } - if shouldUpdate { - r.logger.V(debugLevel).Info("updating ingress config map", "configmap", configMapObjectKey.Name) + return false, nil + } + + shouldUpdate, err := r.shouldUpdateIngressConfigMap(cm, ts) + if err != nil { + return false, err + } - _, err = r.updateIngressConfigMap(ctx, cm, ts) - if err != nil { - return err - } + if !shouldUpdate { + return false, nil + } - configMapUpdated = true + r.logger.V(debugLevel).Info("updating ingress config map", "configmap", configMapObjectKey.Name) + + _, err = r.updateIngressConfigMap(ctx, cm, ts) + if err != nil { + return false, err + } + + return true, nil +} + +// syncIngressDeploymentContainer mutates the reverse proxy deployment's container spec in place to +// match the desired spec, reporting which aspects changed. +func (r *TypesenseClusterReconciler) syncIngressDeploymentContainer(deployment *appsv1.Deployment, ts *tsv1alpha1.TypesenseCluster) (resourcesChanged, imageChanged, readOnlyRootFsChanged bool) { + container := &deployment.Spec.Template.Spec.Containers[0] + + desiredResources := ts.Spec.Ingress.GetReverseProxyResources() + resourcesChanged = !apiequality.Semantic.DeepEqual(desiredResources, container.Resources) + if resourcesChanged { + container.Resources = desiredResources + } + + imageChanged = !apiequality.Semantic.DeepEqual(ts.Spec.Ingress.Image, container.Image) + if imageChanged { + container.Image = ts.Spec.Ingress.Image + } + + if ts.Spec.Ingress.ReadOnlyRootFilesystem == nil { + if container.SecurityContext != nil { + readOnlyRootFsChanged = true + container.SecurityContext = nil + deployment.Spec.Template.Spec.Volumes = r.getDefaultReverseProxyVolumes(ts.Name) + container.VolumeMounts = r.getDefaultReverseProxyVolumeMounts() + } + + return resourcesChanged, imageChanged, readOnlyRootFsChanged + } + + securityContext := ts.Spec.Ingress.ReadOnlyRootFilesystem.SecurityContext + if securityContext == nil { + securityContext = &v1.SecurityContext{ + ReadOnlyRootFilesystem: ptr.To(true), } } + if !apiequality.Semantic.DeepEqual(securityContext, container.SecurityContext) { + readOnlyRootFsChanged = true + container.SecurityContext = securityContext + } + + desiredVolumes := r.getDefaultReverseProxyVolumes(ts.Name) + desiredVolumes = append(desiredVolumes, ts.Spec.Ingress.ReadOnlyRootFilesystem.Volumes...) + + if needsSyncVolumes(desiredVolumes, deployment.Spec.Template.Spec.Volumes) { + readOnlyRootFsChanged = true + deployment.Spec.Template.Spec.Volumes = desiredVolumes + } + + desiredMounts := r.getDefaultReverseProxyVolumeMounts() + desiredMounts = append(desiredMounts, ts.Spec.Ingress.ReadOnlyRootFilesystem.VolumeMounts...) + + if needsSyncMounts(desiredMounts, container.VolumeMounts) { + readOnlyRootFsChanged = true + container.VolumeMounts = desiredMounts + } + + return resourcesChanged, imageChanged, readOnlyRootFsChanged +} + +func (r *TypesenseClusterReconciler) reconcileIngressDeployment(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, ig *networkingv1.Ingress, configMapUpdated bool) error { deploymentName := fmt.Sprintf(ClusterReverseProxy, ts.Name) deploymentExists := true deploymentObjectKey := client.ObjectKey{Namespace: ts.Namespace, Name: deploymentName} @@ -174,78 +262,37 @@ func (r *TypesenseClusterReconciler) ReconcileIngress(ctx context.Context, ts *t if !deploymentExists { r.logger.V(debugLevel).Info("creating ingress reverse proxy deployment", "deployment", deploymentObjectKey.Name) - _, err = r.createIngressDeployment(ctx, deploymentObjectKey, ts, ig) + _, err := r.createIngressDeployment(ctx, deploymentObjectKey, ts, ig) if err != nil { r.logger.Error(err, "creating ingress reverse proxy deployment failed", "deployment", deploymentObjectKey.Name) return err } - } else { - desiredResources := ts.Spec.Ingress.GetReverseProxyResources() - deploymentResourcesNeedUpdate := !apiequality.Semantic.DeepEqual(desiredResources, deployment.Spec.Template.Spec.Containers[0].Resources) - if deploymentResourcesNeedUpdate { - deployment.Spec.Template.Spec.Containers[0].Resources = desiredResources - } - - deploymentImageNeedUpdate := !apiequality.Semantic.DeepEqual(ts.Spec.Ingress.Image, deployment.Spec.Template.Spec.Containers[0].Image) - if deploymentImageNeedUpdate { - deployment.Spec.Template.Spec.Containers[0].Image = ts.Spec.Ingress.Image - } - - readOnlyRootFilesystemSpecsNeedUpdate := false - if ts.Spec.Ingress.ReadOnlyRootFilesystem == nil { - if deployment.Spec.Template.Spec.Containers[0].SecurityContext != nil { - readOnlyRootFilesystemSpecsNeedUpdate = true - deployment.Spec.Template.Spec.Containers[0].SecurityContext = nil - deployment.Spec.Template.Spec.Volumes = r.getDefaultReverseProxyVolumes(ts.Name) - deployment.Spec.Template.Spec.Containers[0].VolumeMounts = r.getDefaultReverseProxyVolumeMounts() - } - } else { - securityContext := ts.Spec.Ingress.ReadOnlyRootFilesystem.SecurityContext - if securityContext == nil { - securityContext = &v1.SecurityContext{ - ReadOnlyRootFilesystem: ptr.To(true), - } - } - - if !apiequality.Semantic.DeepEqual(securityContext, deployment.Spec.Template.Spec.Containers[0].SecurityContext) { - readOnlyRootFilesystemSpecsNeedUpdate = true - deployment.Spec.Template.Spec.Containers[0].SecurityContext = securityContext - } - - desiredVolumes := r.getDefaultReverseProxyVolumes(ts.Name) - desiredVolumes = append(desiredVolumes, ts.Spec.Ingress.ReadOnlyRootFilesystem.Volumes...) - existingVolumes := deployment.Spec.Template.Spec.Volumes - if needsSyncVolumes(desiredVolumes, existingVolumes) { - readOnlyRootFilesystemSpecsNeedUpdate = true - deployment.Spec.Template.Spec.Volumes = desiredVolumes - } + return nil + } - desiredMounts := r.getDefaultReverseProxyVolumeMounts() - desiredMounts = append(desiredMounts, ts.Spec.Ingress.ReadOnlyRootFilesystem.VolumeMounts...) + resourcesChanged, imageChanged, readOnlyRootFsChanged := r.syncIngressDeploymentContainer(deployment, ts) - existingMounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - if needsSyncMounts(desiredMounts, existingMounts) { - readOnlyRootFilesystemSpecsNeedUpdate = true - deployment.Spec.Template.Spec.Containers[0].VolumeMounts = desiredMounts - } - } + if !configMapUpdated && !resourcesChanged && !imageChanged && !readOnlyRootFsChanged { + return nil + } - if configMapUpdated || deploymentResourcesNeedUpdate || deploymentImageNeedUpdate || readOnlyRootFilesystemSpecsNeedUpdate { - if deployment.Spec.Template.Annotations == nil { - deployment.Spec.Template.Annotations = make(map[string]string) - } + if deployment.Spec.Template.Annotations == nil { + deployment.Spec.Template.Annotations = make(map[string]string) + } - r.logger.V(debugLevel).Info("adding restart annotation to ingress reverse proxy deployment", "deployment", deploymentObjectKey.Name) - deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + r.logger.V(debugLevel).Info("adding restart annotation to ingress reverse proxy deployment", "deployment", deploymentObjectKey.Name) + deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) - if err := r.Update(ctx, deployment); err != nil { - r.logger.Error(err, "adding restart annotation to ingress reverse proxy deployment failed", "deployment", deploymentObjectKey.Name) - return err - } - } + if err := r.Update(ctx, deployment); err != nil { + r.logger.Error(err, "adding restart annotation to ingress reverse proxy deployment failed", "deployment", deploymentObjectKey.Name) + return err } + return nil +} + +func (r *TypesenseClusterReconciler) reconcileIngressServiceResource(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, ig *networkingv1.Ingress) error { serviceName := fmt.Sprintf(ClusterReverseProxyService, ts.Name) serviceExists := true serviceNameObjectKey := client.ObjectKey{Namespace: ts.Namespace, Name: serviceName} @@ -263,18 +310,19 @@ func (r *TypesenseClusterReconciler) ReconcileIngress(ctx context.Context, ts *t if !serviceExists { r.logger.V(debugLevel).Info("creating ingress reverse proxy service", "service", serviceNameObjectKey.Name) - _, err = r.createIngressService(ctx, serviceNameObjectKey, ts, ig) + _, err := r.createIngressService(ctx, serviceNameObjectKey, ts, ig) if err != nil { r.logger.Error(err, "creating ingress reverse proxy service failed", "service", serviceNameObjectKey.Name) return err } - } else { - if !apiequality.Semantic.DeepEqual(service.Annotations, ts.Spec.Ingress.ServiceAnnotations) { - err = r.updateIngressService(ctx, service, ts) - if err != nil { - r.logger.Error(err, "updating ingress reverse proxy service failed", "service", serviceNameObjectKey.Name) - return err - } + + return nil + } + + if !apiequality.Semantic.DeepEqual(service.Annotations, ts.Spec.Ingress.ServiceAnnotations) { + if err := r.updateIngressService(ctx, service, ts); err != nil { + r.logger.Error(err, "updating ingress reverse proxy service failed", "service", serviceNameObjectKey.Name) + return err } } @@ -398,8 +446,8 @@ func (r *TypesenseClusterReconciler) deleteIngress(ctx context.Context, ig *netw } func (r *TypesenseClusterReconciler) getIngressLabels(ig *networkingv1.Ingress, ts *tsv1alpha1.TypesenseCluster, key client.ObjectKey) map[string]string { - var filters []string defaultLabels := getIngressObjectMeta(ts, &key.Name, nil, nil).Labels + filters := make([]string, 0, len(defaultLabels)) for k := range defaultLabels { filters = append(filters, k) } @@ -423,7 +471,7 @@ func (r *TypesenseClusterReconciler) createIngressConfigMap(ctx context.Context, icm := &v1.ConfigMap{ ObjectMeta: getReverseProxyObjectMeta(ts, &key.Name, nil), Data: map[string]string{ - "nginx.conf": nginxConf, + nginxConfValue: nginxConf, }, } @@ -448,7 +496,7 @@ func (r *TypesenseClusterReconciler) updateIngressConfigMap(ctx context.Context, desired := cm.DeepCopy() desired.Data = map[string]string{ - "nginx.conf": nginxConf, + nginxConfValue: nginxConf, } err = r.Update(ctx, desired) @@ -466,7 +514,7 @@ func (r *TypesenseClusterReconciler) shouldUpdateIngressConfigMap(cm *v1.ConfigM return false, err } - return cm.Data["nginx.conf"] != nginxConf, nil + return cm.Data[nginxConfValue] != nginxConf, nil } func (r *TypesenseClusterReconciler) getIngressNginxConf(ts *tsv1alpha1.TypesenseCluster) (string, error) { @@ -543,7 +591,7 @@ func (r *TypesenseClusterReconciler) getDefaultReverseProxyVolumeMounts() []v1.V { Name: "nginx-config", MountPath: "/etc/nginx/nginx.conf", - SubPath: "nginx.conf", + SubPath: nginxConfValue, }, } } @@ -622,7 +670,7 @@ func (r *TypesenseClusterReconciler) createIngressService(ctx context.Context, k Protocol: v1.ProtocolTCP, Port: 80, TargetPort: intstr.IntOrString{Type: intstr.Int, IntVal: int32(80)}, - Name: "http", + Name: httpPortName, }, }, }, @@ -643,10 +691,10 @@ func (r *TypesenseClusterReconciler) createIngressService(ctx context.Context, k func (r *TypesenseClusterReconciler) updateIngressService(ctx context.Context, svc *v1.Service, ts *tsv1alpha1.TypesenseCluster) error { patch := client.MergeFrom(svc.DeepCopy()) - if svc.ObjectMeta.Annotations == nil { - svc.ObjectMeta.Annotations = map[string]string{} + if svc.Annotations == nil { + svc.Annotations = map[string]string{} } - svc.ObjectMeta.Annotations = ts.Spec.Ingress.ServiceAnnotations + svc.Annotations = ts.Spec.Ingress.ServiceAnnotations if err := r.Patch(ctx, svc, patch); err != nil { return err diff --git a/internal/controller/typesensecluster_podmonitor.go b/internal/controller/typesensecluster_podmonitor.go index c6fe7cf..e9f7814 100644 --- a/internal/controller/typesensecluster_podmonitor.go +++ b/internal/controller/typesensecluster_podmonitor.go @@ -63,7 +63,7 @@ func (r *TypesenseClusterReconciler) ReconcilePodMonitor(ctx context.Context, ts return err } } else { - if ts.Spec.Metrics.Release != podMonitor.ObjectMeta.Labels["release"] || monitoringv1.Duration(fmt.Sprintf("%ds", ts.Spec.Metrics.IntervalInSeconds)) != podMonitor.Spec.PodMetricsEndpoints[0].Interval { + if ts.Spec.Metrics.Release != podMonitor.Labels["release"] || monitoringv1.Duration(fmt.Sprintf("%ds", ts.Spec.Metrics.IntervalInSeconds)) != podMonitor.Spec.PodMetricsEndpoints[0].Interval { r.logger.V(debugLevel).Info("updating podmonitor", "podmonitor", podMonitorObjectKey.Name) err := r.deleteMetricsExporterPodMonitor(ctx, podMonitor) @@ -101,7 +101,7 @@ func (r *TypesenseClusterReconciler) createMetricsExporterPodMonitor(ctx context Port: "metrics", Path: "/metrics", Interval: monitoringv1.Duration(fmt.Sprintf("%ds", ts.Spec.Metrics.IntervalInSeconds)), - Scheme: "http", + Scheme: httpPortName, }, }, }, diff --git a/internal/controller/typesensecluster_quorum.go b/internal/controller/typesensecluster_quorum.go index b553f0a..82d1519 100644 --- a/internal/controller/typesensecluster_quorum.go +++ b/internal/controller/typesensecluster_quorum.go @@ -22,43 +22,7 @@ const ( HealthyReadLagDefaultValue = 1000 ) -func (r *TypesenseClusterReconciler) ReconcileQuorum(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, secret *v1.Secret, stsObjectKey client.ObjectKey) (ConditionQuorum, int, error) { - r.logger.Info("reconciling quorum health") - - sts, err := r.GetFreshStatefulSet(ctx, stsObjectKey) - if err != nil { - return ConditionReasonQuorumNotReady, 0, err - } - - quorum, err := r.getQuorum(ctx, ts, sts) - if err != nil { - return ConditionReasonQuorumNotReady, 0, err - } - - r.logger.Info("calculated quorum", "minRequiredNodes", quorum.MinRequiredNodes, "availableNodes", quorum.AvailableNodes) - - if quorum.AvailableNodes != int(ts.Spec.Replicas) { - r.logger.Info("resizing quorum pending", "size", ts.Spec.Replicas) - } - - unscheduledPods, _ := r.GetUnscheduledPods(ctx, sts) - if len(unscheduledPods) > 0 { - _ = r.RestartUnscheduledPods(ctx, unscheduledPods, ts) - } - - if quorum.AvailableNodes < quorum.MinRequiredNodes { - return ConditionReasonStatefulSetNotReady, 0, nil - } - - nodesStatus := make(map[string]NodeStatus) - httpClient, err := r.getHttpClient(ts) - if err != nil { - return ConditionReasonQuorumNotReady, 0, err - } - - queuedWrites := 0 - healthyWriteLagThreshold := r.getHealthyWriteLagThreshold(ctx, ts) - +func (r *TypesenseClusterReconciler) buildNodeEndpoints(quorum *Quorum) []NodeEndpoint { nodeKeys := make([]string, 0, len(quorum.Nodes)) for k := range quorum.Nodes { nodeKeys = append(nodeKeys, k) @@ -67,15 +31,17 @@ func (r *TypesenseClusterReconciler) ReconcileQuorum(ctx context.Context, ts *ts nodeEndpoints := make([]NodeEndpoint, 0, len(quorum.Nodes)) for _, key := range nodeKeys { - ne := NodeEndpoint{ + nodeEndpoints = append(nodeEndpoints, NodeEndpoint{ PodName: key, IP: quorum.Nodes[key], - } - - nodeEndpoints = append(nodeEndpoints, ne) + }) } - logs := make(map[string]string, len(quorum.Nodes)) + return nodeEndpoints +} + +func (r *TypesenseClusterReconciler) getNodesLogs(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, nodeEndpoints []NodeEndpoint) map[string]string { + logs := make(map[string]string, len(nodeEndpoints)) for _, ne := range nodeEndpoints { l, err := r.getPodLogs(ctx, ne, ts.Namespace) if err != nil { @@ -85,7 +51,14 @@ func (r *TypesenseClusterReconciler) ReconcileQuorum(ctx context.Context, ts *ts logs[ne.PodName] = l } - //quorum.Nodes are coming straight from the PodList of Statefulset + return logs +} + +// getNodesStatus reports on every node's status. quorum.Nodes are coming straight from the PodList of Statefulset. +func (r *TypesenseClusterReconciler) getNodesStatus(ctx context.Context, httpClient *http.Client, ts *tsv1alpha1.TypesenseCluster, secret *v1.Secret, nodeEndpoints []NodeEndpoint, logs map[string]string) (map[string]NodeStatus, int) { + nodesStatus := make(map[string]NodeStatus) + queuedWrites := 0 + for _, ne := range nodeEndpoints { status, err := r.getNodeStatus(ctx, httpClient, ne, ts, secret, logs[ne.PodName]) if err != nil { @@ -106,28 +79,16 @@ func (r *TypesenseClusterReconciler) ReconcileQuorum(ctx context.Context, ts *ts ne.IP, "queued_writes", status.QueuedWrites, - "commited_index", + "committed_index", status.CommittedIndex, ) nodesStatus[ne.PodName] = status } - clusterStatus := r.getClusterStatus(nodesStatus) - r.logger.V(debugLevel).Info("reporting cluster status", "status", clusterStatus) - - if clusterStatus == ClusterStatusSplitBrain { - hbv, err := r.hasBootstrapValues(ts, quorum.NodesListConfigMap) - if err != nil { - return ConditionReasonQuorumNotReady, 0, err - } - - if hbv { - return ConditionReasonQuorumNotReadyWaitATerm, 0, nil - } - - return r.downgradeQuorum(ctx, ts, quorum.NodesListConfigMap, stsObjectKey, sts.Status.ReadyReplicas, int32(quorum.MinRequiredNodes)) - } + return nodesStatus, queuedWrites +} +func (r *TypesenseClusterReconciler) updatePodReadinessGates(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, httpClient *http.Client, nodeEndpoints []NodeEndpoint, nodesStatus map[string]NodeStatus, logs map[string]string) (map[string]bool, bool, error) { clusterNeedsAttention := false nodesHealth := make(map[string]bool) @@ -143,15 +104,78 @@ func (r *TypesenseClusterReconciler) ReconcileQuorum(ctx context.Context, ts *ts nodesHealth[key], _ = strconv.ParseBool(string(condition.Status)) podPrefix := fmt.Sprintf(ClusterStatefulSet, ts.Name) - podIndex := strings.Replace(key, fmt.Sprintf("%s-", podPrefix), "", -1) + podIndex := strings.ReplaceAll(key, fmt.Sprintf("%s-", podPrefix), "") podName := fmt.Sprintf("%s-%s", podPrefix, podIndex) podObjectKey := client.ObjectKey{Namespace: ts.Namespace, Name: podName} - err = r.updatePodReadinessGate(ctx, podObjectKey, condition) + err := r.updatePodReadinessGate(ctx, podObjectKey, condition) if err != nil { r.logger.Error(err, fmt.Sprintf("unable to update statefulset pod: %s", podObjectKey.Name)) + return nodesHealth, clusterNeedsAttention, err + } + } + + return nodesHealth, clusterNeedsAttention, nil +} + +func (r *TypesenseClusterReconciler) ReconcileQuorum(ctx context.Context, ts *tsv1alpha1.TypesenseCluster, secret *v1.Secret, stsObjectKey client.ObjectKey) (ConditionQuorum, int, error) { + r.logger.Info("reconciling quorum health") + + sts, err := r.GetFreshStatefulSet(ctx, stsObjectKey) + if err != nil { + return ConditionReasonQuorumNotReady, 0, err + } + + quorum, err := r.getQuorum(ctx, ts, sts) + if err != nil { + return ConditionReasonQuorumNotReady, 0, err + } + + r.logger.Info("calculated quorum", "minRequiredNodes", quorum.MinRequiredNodes, "availableNodes", quorum.AvailableNodes) + + if quorum.AvailableNodes != int(ts.Spec.Replicas) { + r.logger.Info("resizing quorum pending", "size", ts.Spec.Replicas) + } + + unscheduledPods, _ := r.GetUnscheduledPods(ctx, sts) + if len(unscheduledPods) > 0 { + _ = r.RestartUnscheduledPods(ctx, unscheduledPods, ts) + } + + if quorum.AvailableNodes < quorum.MinRequiredNodes { + return ConditionReasonStatefulSetNotReady, 0, nil + } + + httpClient, err := r.getHttpClient(ts) + if err != nil { + return ConditionReasonQuorumNotReady, 0, err + } + + healthyWriteLagThreshold := r.getHealthyWriteLagThreshold(ctx, ts) + + nodeEndpoints := r.buildNodeEndpoints(quorum) + logs := r.getNodesLogs(ctx, ts, nodeEndpoints) + nodesStatus, queuedWrites := r.getNodesStatus(ctx, httpClient, ts, secret, nodeEndpoints, logs) + + clusterStatus := r.getClusterStatus(nodesStatus) + r.logger.V(debugLevel).Info("reporting cluster status", "status", clusterStatus) + + if clusterStatus == ClusterStatusSplitBrain { + hbv, err := r.hasBootstrapValues(ts, quorum.NodesListConfigMap) + if err != nil { return ConditionReasonQuorumNotReady, 0, err } + + if hbv { + return ConditionReasonQuorumNotReadyWaitATerm, 0, nil + } + + return r.downgradeQuorum(ctx, ts, quorum.NodesListConfigMap, stsObjectKey, sts.Status.ReadyReplicas, int32(quorum.MinRequiredNodes)) + } + + nodesHealth, clusterNeedsAttention, err := r.updatePodReadinessGates(ctx, ts, httpClient, nodeEndpoints, nodesStatus, logs) + if err != nil { + return ConditionReasonQuorumNotReady, 0, err } if clusterNeedsAttention { @@ -238,7 +262,7 @@ func (r *TypesenseClusterReconciler) downgradeQuorum( stsObjectKey client.ObjectKey, healthyNodes, minRequiredNodes int32, ) (ConditionQuorum, int, error) { - //r.logger.Info("downgrading quorum") + // r.logger.Info("downgrading quorum") r.logger.V(debugLevel).Info("scaling statefulset", "sts", stsObjectKey.Name, "triggers", QuorumDowngraded) sts, err := r.GetFreshStatefulSet(ctx, stsObjectKey) @@ -262,7 +286,7 @@ func (r *TypesenseClusterReconciler) downgradeQuorum( } } - _, size, updated, err := r.updateConfigMap(ctx, ts, cm, ptr.To[int32](desiredReplicas), true) + size, updated, err := r.updateConfigMap(ctx, ts, cm, ptr.To[int32](desiredReplicas), true) if err != nil { return ConditionReasonQuorumNotReady, 0, err } @@ -280,7 +304,7 @@ func (r *TypesenseClusterReconciler) upgradeQuorum( cm *v1.ConfigMap, stsObjectKey client.ObjectKey, ) (ConditionQuorum, int, error) { - //r.logger.Info("upgrading quorum", "incremental", ts.Spec.IncrementalQuorumRecovery) + // r.logger.Info("upgrading quorum", "incremental", ts.Spec.IncrementalQuorumRecovery) r.logger.V(debugLevel).Info("scaling statefulset", "sts", stsObjectKey.Name, "triggers", QuorumUpgraded, "incremental", ts.Spec.IncrementalQuorumRecovery) sts, err := r.GetFreshStatefulSet(ctx, stsObjectKey) @@ -297,7 +321,7 @@ func (r *TypesenseClusterReconciler) upgradeQuorum( return ConditionReasonQuorumNotReady, 0, err } - _, _, updated, err := r.updateConfigMap(ctx, ts, cm, &size, true) + _, updated, err := r.updateConfigMap(ctx, ts, cm, &size, true) if err != nil { return ConditionReasonQuorumNotReady, 0, err } @@ -331,9 +355,7 @@ func (r *TypesenseClusterReconciler) calculatePodReadinessGate(ctx context.Conte } else { if !health.Ok { if health.ResourceError != nil && (*health.ResourceError == OutOfMemory || *health.ResourceError == OutOfDisk) { - conditionReason = nodeNotRecoverable conditionMessage = fmt.Sprintf("node is failing: %s", string(*health.ResourceError)) - conditionStatus = v1.ConditionFalse err := fmt.Errorf("health check reported a blocking node error on %s: %s", r.getShortName(node.PodName), string(*health.ResourceError)) r.logger.Error(err, "quorum cannot be recovered automatically") @@ -388,6 +410,6 @@ func (r *TypesenseClusterReconciler) updatePodReadinessGate(ctx context.Context, return err } - //r.logger.V(debugLevel).Info("updating pod readiness gate condition", "pod", pod.Name, "condition", condition.Type, "conditionStatus", condition.Status) + // r.logger.V(debugLevel).Info("updating pod readiness gate condition", "pod", pod.Name, "condition", condition.Type, "conditionStatus", condition.Status) return nil } diff --git a/internal/controller/typesensecluster_quorum_helpers.go b/internal/controller/typesensecluster_quorum_helpers.go index 80b4a4d..93cddff 100644 --- a/internal/controller/typesensecluster_quorum_helpers.go +++ b/internal/controller/typesensecluster_quorum_helpers.go @@ -42,7 +42,7 @@ func (r *TypesenseClusterReconciler) getNodeStatus(ctx context.Context, httpClie r.logger.Error(err, "request failed") return NodeStatus{State: UnreachableState}, nil } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { r.logger.Error(err, "error executing node status request", "httpStatusCode", resp.StatusCode, "ip", node.IP.String()) @@ -128,7 +128,7 @@ func (r *TypesenseClusterReconciler) getNodeHealth(ctx context.Context, httpClie r.logger.Error(err, "request failed") return NodeHealth{Ok: false}, nil } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -183,13 +183,13 @@ func (r *TypesenseClusterReconciler) getQuorum(ctx context.Context, ts *tsv1alph for _, pod := range pods.Items { if pod.Status.PodIP != "" { raftEndpoint := fmt.Sprintf("%s:%d:%d", pod.Status.PodIP, ts.Spec.PeeringPort, ts.Spec.ApiPort) - if _, contains := contains(nodes, raftEndpoint); contains { + if contains(nodes, raftEndpoint) { qn[pod.Name] = net.ParseIP(pod.Status.PodIP) } } } - return &Quorum{minRequiredNodes, int(availableNodes), qn, cm}, nil + return &Quorum{minRequiredNodes, availableNodes, qn, cm}, nil } func getMinimumRequiredNodes(availableNodes int) int { @@ -224,9 +224,9 @@ func (r *TypesenseClusterReconciler) getHealthyWriteLagThreshold(ctx context.Con return healthyWriteLag } -func (r *TypesenseClusterReconciler) getHealthyReadLagThreshold(ctx context.Context, ts *tsv1alpha1.TypesenseCluster) int { +func (r *TypesenseClusterReconciler) getHealthyLagThresholds(ctx context.Context, ts *tsv1alpha1.TypesenseCluster) (int, int) { if ts.Spec.AdditionalServerConfiguration == nil { - return HealthyReadLagDefaultValue + return HealthyReadLagDefaultValue, HealthyWriteLagDefaultValue } configMapName := ts.Spec.AdditionalServerConfiguration.Name @@ -235,38 +235,7 @@ func (r *TypesenseClusterReconciler) getHealthyReadLagThreshold(ctx context.Cont var cm = &v1.ConfigMap{} if err := r.Get(ctx, configMapObjectKey, cm); err != nil { r.logger.Error(err, "unable to additional server configuration config map", "configMap", configMapName) - return HealthyReadLagDefaultValue - } - - healthyReadLagValue := cm.Data[HealthyReadLagKey] - if healthyReadLagValue == "" { - return HealthyReadLagDefaultValue - } - - healthyReadLag, err := strconv.Atoi(healthyReadLagValue) - if err != nil { - r.logger.Error(err, "unable to parse server configuration value", "configMap", configMapName, "key", HealthyReadLagKey) - return HealthyReadLagDefaultValue - } - - return healthyReadLag -} - -func (r *TypesenseClusterReconciler) getHealthyLagThresholds(ctx context.Context, ts *tsv1alpha1.TypesenseCluster) (read int, write int) { - read = HealthyReadLagDefaultValue - write = HealthyWriteLagDefaultValue - - if ts.Spec.AdditionalServerConfiguration == nil { - return - } - - configMapName := ts.Spec.AdditionalServerConfiguration.Name - configMapObjectKey := client.ObjectKey{Namespace: ts.Namespace, Name: configMapName} - - var cm = &v1.ConfigMap{} - if err := r.Get(ctx, configMapObjectKey, cm); err != nil { - r.logger.Error(err, "unable to additional server configuration config map", "configMap", configMapName) - return + return HealthyReadLagDefaultValue, HealthyWriteLagDefaultValue } healthyReadLagValue := cm.Data[HealthyReadLagKey] @@ -289,10 +258,7 @@ func (r *TypesenseClusterReconciler) getHealthyLagThresholds(ctx context.Context r.logger.Error(err, "unable to parse server configuration value", "configMap", configMapName, "key", HealthyWriteLagKey) } - read = healthyReadLag - write = healthyWriteLag - - return + return healthyReadLag, healthyWriteLag } func (r *TypesenseClusterReconciler) getHttpClient(ts *tsv1alpha1.TypesenseCluster) (*http.Client, error) { @@ -334,9 +300,9 @@ func (r *TypesenseClusterReconciler) buildUrl(node NodeEndpoint, ts *tsv1alpha1. func (r *TypesenseClusterReconciler) getPodLogs(ctx context.Context, node NodeEndpoint, namespace string) (string, error) { opts := &v1.PodLogOptions{ - Container: "typesense", + Container: typesenseValue, TailLines: ptr.To[int64](50), - //SinceSeconds: ptr.To[int64](120), + // SinceSeconds: ptr.To[int64](120), } req := r.ClientSet.CoreV1().Pods(namespace).GetLogs(node.PodName, opts) diff --git a/internal/controller/typesensecluster_quorum_types.go b/internal/controller/typesensecluster_quorum_types.go index 6ef2427..730f25f 100644 --- a/internal/controller/typesensecluster_quorum_types.go +++ b/internal/controller/typesensecluster_quorum_types.go @@ -1,8 +1,9 @@ package controller import ( - v1 "k8s.io/api/core/v1" "net" + + v1 "k8s.io/api/core/v1" ) type NodeState string diff --git a/internal/controller/typesensecluster_scraper.go b/internal/controller/typesensecluster_scraper.go index be3f9f5..1ee441f 100644 --- a/internal/controller/typesensecluster_scraper.go +++ b/internal/controller/typesensecluster_scraper.go @@ -143,7 +143,7 @@ func (r *TypesenseClusterReconciler) createScraper(ctx context.Context, key clie Value: scraperSpec.GetScraperConfig(), }, { - Name: "TYPESENSE_API_KEY", + Name: envTypesenseApiKey, ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ Key: ClusterAdminApiKeySecretKeyName, @@ -162,8 +162,8 @@ func (r *TypesenseClusterReconciler) createScraper(ctx context.Context, key clie Value: strconv.Itoa(ts.Spec.ApiPort), }, { - Name: "TYPESENSE_PROTOCOL", - Value: "http", + Name: envTypesenseProtocol, + Value: httpPortName, }, }, EnvFrom: scraperSpec.GetScraperAuthConfiguration(), diff --git a/internal/controller/typesensecluster_secret.go b/internal/controller/typesensecluster_secret.go index c53a9c9..a1386a2 100644 --- a/internal/controller/typesensecluster_secret.go +++ b/internal/controller/typesensecluster_secret.go @@ -3,6 +3,7 @@ package controller import ( "context" "fmt" + tsv1alpha1 "github.com/akyriako/typesense-operator/api/v1alpha1" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" diff --git a/internal/controller/typesensecluster_services.go b/internal/controller/typesensecluster_services.go index 5907a1e..be1fb99 100644 --- a/internal/controller/typesensecluster_services.go +++ b/internal/controller/typesensecluster_services.go @@ -67,7 +67,7 @@ func (r *TypesenseClusterReconciler) ReconcileServices(ctx context.Context, ts t if !svcExists { r.logger.V(debugLevel).Info("creating resolver service", "service", svcObjectKey.Name) - _, err := r.createService(ctx, svcObjectKey, &ts) + err := r.createService(ctx, svcObjectKey, &ts) if err != nil { r.logger.Error(err, "creating resolver service failed", "service", svcObjectKey.Name) return err @@ -80,7 +80,7 @@ func (r *TypesenseClusterReconciler) ReconcileServices(ctx context.Context, ts t return err } - _, err = r.createService(ctx, svcObjectKey, &ts) + err = r.createService(ctx, svcObjectKey, &ts) if err != nil { r.logger.Error(err, "creating resolver service failed", "service", svcObjectKey.Name) return err @@ -125,7 +125,7 @@ func (r *TypesenseClusterReconciler) createHeadlessService(ctx context.Context, Selector: getLabels(ts), Ports: []v1.ServicePort{ { - Name: "http", + Name: httpPortName, Port: int32(ts.Spec.ApiPort), TargetPort: intstr.IntOrString{IntVal: 8108}, }, @@ -157,7 +157,7 @@ func (r *TypesenseClusterReconciler) updateHeadlessService(ctx context.Context, return nil } -func (r *TypesenseClusterReconciler) createService(ctx context.Context, key client.ObjectKey, ts *tsv1alpha1.TypesenseCluster) (*v1.Service, error) { +func (r *TypesenseClusterReconciler) createService(ctx context.Context, key client.ObjectKey, ts *tsv1alpha1.TypesenseCluster) error { svc := &v1.Service{ ObjectMeta: getObjectMeta(ts, &key.Name, getMergedAnnotations(ts)), Spec: v1.ServiceSpec{ @@ -165,12 +165,12 @@ func (r *TypesenseClusterReconciler) createService(ctx context.Context, key clie Selector: getLabels(ts), Ports: []v1.ServicePort{ { - Name: "http", + Name: httpPortName, Port: int32(ts.Spec.ApiPort), TargetPort: intstr.IntOrString{IntVal: 8108}, }, { - Name: "healthcheck", + Name: healthcheckValue, Port: 8808, TargetPort: intstr.IntOrString{IntVal: 8808}, }, @@ -182,7 +182,7 @@ func (r *TypesenseClusterReconciler) createService(ctx context.Context, key clie svcType := ts.Spec.Service.Type svcExternalTrafficPolicy, err := r.invalidateExternalTrafficPolicy(svcType, ts.Spec.Service) if err != nil { - return nil, err + return err } svc.Spec.Type = svcType @@ -193,15 +193,10 @@ func (r *TypesenseClusterReconciler) createService(ctx context.Context, key clie err := ctrl.SetControllerReference(ts, svc, r.Scheme) if err != nil { - return nil, err - } - - err = r.Create(ctx, svc) - if err != nil { - return nil, err + return err } - return svc, nil + return r.Create(ctx, svc) } func (r *TypesenseClusterReconciler) updateService(ctx context.Context, svc *v1.Service, ts *tsv1alpha1.TypesenseCluster) error { @@ -225,10 +220,10 @@ func (r *TypesenseClusterReconciler) updateService(ctx context.Context, svc *v1. svc.Spec.ExternalTrafficPolicy = *svcExternalTrafficPolicy } - if svc.ObjectMeta.Annotations == nil { - svc.ObjectMeta.Annotations = map[string]string{} + if svc.Annotations == nil { + svc.Annotations = map[string]string{} } - svc.ObjectMeta.Annotations = getMergedAnnotations(ts) + svc.Annotations = getMergedAnnotations(ts) if err := r.Patch(ctx, svc, patch); err != nil { return err diff --git a/internal/controller/typesensecluster_statefulset.go b/internal/controller/typesensecluster_statefulset.go index a969236..fa00dbb 100644 --- a/internal/controller/typesensecluster_statefulset.go +++ b/internal/controller/typesensecluster_statefulset.go @@ -68,7 +68,7 @@ func (r *TypesenseClusterReconciler) ReconcileStatefulSet(ctx context.Context, t string(ConditionReasonQuorumDowngraded), string(ConditionReasonQuorumUpgraded), string(ConditionReasonQuorumNeedsAttentionMemoryOrDiskIssue), - //string(ConditionReasonQuorumNeedsAttentionClusterIsLagging), + // string(ConditionReasonQuorumNeedsAttentionClusterIsLagging), string(ConditionReasonQuorumNotReady), ConditionReasonStatefulSetNotReady, ConditionReasonReconciliationInProgress, @@ -79,7 +79,7 @@ func (r *TypesenseClusterReconciler) ReconcileStatefulSet(ctx context.Context, t if condition != nil { emergencyUpdateRequired := r.shouldEmergencyUpdateStatefulSet(sts, ts) - if _, contains := contains(skipConditions, condition.Reason); !contains || emergencyUpdateRequired { + if !contains(skipConditions, condition.Reason) || emergencyUpdateRequired { desiredSts, err := r.buildStatefulSet(ctx, stsObjectKey, ts) if err != nil { r.logger.Error(err, "building statefulset failed", "sts", stsObjectKey.Name) @@ -110,7 +110,7 @@ func (r *TypesenseClusterReconciler) ReconcileStatefulSet(ctx context.Context, t r.logger.V(debugLevel).Error(err, fmt.Sprintf("unable to fetch config map: %s", configMapName)) } - _, _, updated, err := r.updateConfigMap(ctx, ts, cm, updatedSts.Spec.Replicas, true) + _, updated, err := r.updateConfigMap(ctx, ts, cm, updatedSts.Spec.Replicas, true) if err != nil { r.logger.V(debugLevel).Error(err, fmt.Sprintf("unable to update config map: %s", configMapName)) } @@ -137,7 +137,7 @@ func (r *TypesenseClusterReconciler) ReconcileStatefulSet(ctx context.Context, t if err := r.Get(ctx, configMapObjectKey, cm); err != nil { r.logger.V(debugLevel).Error(err, fmt.Sprintf("unable to fetch config map: %s", configMapName)) } - _, _, updated, err := r.updateConfigMap(ctx, ts, cm, &size, true) + _, updated, err := r.updateConfigMap(ctx, ts, cm, &size, true) if err != nil { return desiredSts, true, err } @@ -195,7 +195,7 @@ func (r *TypesenseClusterReconciler) updateStatefulSet(ctx context.Context, sts patch := client.MergeFrom(sts.DeepCopy()) sts.Spec = desired.Spec - sts.ObjectMeta.Annotations = desired.ObjectMeta.Annotations + sts.Annotations = desired.Annotations if sts.Spec.Template.Annotations == nil { sts.Spec.Template.Annotations = map[string]string{} @@ -257,19 +257,19 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c ImagePullSecrets: ts.Spec.ImagePullSecrets, Containers: []corev1.Container{ { - Name: "typesense", + Name: typesenseValue, Image: ts.Spec.Image, ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: ts.Spec.GetTypesenseSecurityContext(), Ports: []corev1.ContainerPort{ { - Name: "http", + Name: httpPortName, ContainerPort: int32(ts.Spec.ApiPort), }, }, Env: []corev1.EnvVar{ { - Name: "TYPESENSE_API_KEY", + Name: envTypesenseApiKey, ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ Key: ClusterAdminApiKeySecretKeyName, @@ -320,11 +320,11 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c VolumeMounts: []corev1.VolumeMount{ { MountPath: "/usr/share/typesense", - Name: "nodeslist", + Name: nodesListValue, }, { MountPath: "/usr/share/typesense/data", - Name: "data", + Name: dataValue, }, }, }, @@ -341,7 +341,7 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c }, Env: []corev1.EnvVar{ { - Name: "TYPESENSE_API_KEY", + Name: envTypesenseApiKey, ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ Key: ClusterAdminApiKeySecretKeyName, @@ -356,8 +356,8 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c Value: strconv.Itoa(ts.Spec.GetMetricsExporterSpecs().LogLevel), }, { - Name: "TYPESENSE_PROTOCOL", - Value: "http", + Name: envTypesenseProtocol, + Value: httpPortName, }, { Name: "TYPESENSE_HOST", @@ -379,19 +379,19 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c Resources: ts.Spec.GetMetricsExporterResources(), }, { - Name: "healthcheck", + Name: healthcheckValue, Image: ts.Spec.GetHealthCheckSidecarSpecs().Image, ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: ts.Spec.GetHealthcheckSecurityContext(), Ports: []corev1.ContainerPort{ { - Name: "healthcheck", + Name: healthcheckValue, ContainerPort: 8808, }, }, Env: []corev1.EnvVar{ { - Name: "TYPESENSE_API_KEY", + Name: envTypesenseApiKey, ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ Key: ClusterAdminApiKeySecretKeyName, @@ -406,8 +406,8 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c Value: strconv.Itoa(ts.Spec.GetHealthCheckSidecarSpecs().LogLevel), }, { - Name: "TYPESENSE_PROTOCOL", - Value: "http", + Name: envTypesenseProtocol, + Value: httpPortName, }, { Name: "TYPESENSE_API_PORT", @@ -434,7 +434,7 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c VolumeMounts: []corev1.VolumeMount{ { MountPath: "/usr/share/typesense", - Name: "nodeslist", + Name: nodesListValue, ReadOnly: true, }, }, @@ -446,7 +446,7 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c TopologySpreadConstraints: ts.Spec.GetTopologySpreadConstraints(getLabels(ts)), Volumes: []corev1.Volume{ { - Name: "nodeslist", + Name: nodesListValue, VolumeSource: corev1.VolumeSource{ ConfigMap: &corev1.ConfigMapVolumeSource{ LocalObjectReference: corev1.LocalObjectReference{ @@ -456,10 +456,10 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c }, }, { - Name: "data", + Name: dataValue, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: "data", + ClaimName: dataValue, }, }, }, @@ -469,7 +469,7 @@ func (r *TypesenseClusterReconciler) buildStatefulSet(ctx context.Context, key c VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ { ObjectMeta: metav1.ObjectMeta{ - Name: "data", + Name: dataValue, Labels: getLabels(ts), Annotations: ts.Spec.GetStorage().Annotations, }, @@ -517,7 +517,7 @@ func (r *TypesenseClusterReconciler) ScaleStatefulSet(ctx context.Context, stsOb desired := sts.DeepCopy() desired.Spec.Replicas = &desiredReplicas - if err := r.Client.Update(ctx, desired); err != nil { + if err := r.Update(ctx, desired); err != nil { r.logger.Error(err, "updating stateful replicas failed", "name", desired.Name) return err } diff --git a/internal/controller/typesensecluster_statefulset_hash.go b/internal/controller/typesensecluster_statefulset_hash.go index 7011d28..bf4326a 100644 --- a/internal/controller/typesensecluster_statefulset_hash.go +++ b/internal/controller/typesensecluster_statefulset_hash.go @@ -56,7 +56,7 @@ func (r *TypesenseClusterReconciler) shouldUpdateStatefulSet(sts *appsv1.Statefu } mutatedAnnotations := ts.Spec.IgnoreAnnotationsFromExternalMutations - stsAnnotations := filterMap(sts.ObjectMeta.Annotations, append([]string{rancherDomainAnnotationKey}, mutatedAnnotations...)...) + stsAnnotations := filterMap(sts.Annotations, append([]string{rancherDomainAnnotationKey}, mutatedAnnotations...)...) podAnnotations := filterMap(sts.Spec.Template.Annotations, append([]string{restartPodsAnnotationKey, rancherDomainAnnotationKey}, mutatedAnnotations...)...) // PodAnnotationsChanged @@ -66,16 +66,16 @@ func (r *TypesenseClusterReconciler) shouldUpdateStatefulSet(sts *appsv1.Statefu } // StatefulSetAnnotationsChanged - if !apiequality.Semantic.DeepEqual(stsAnnotations, desired.ObjectMeta.Annotations) { + if !apiequality.Semantic.DeepEqual(stsAnnotations, desired.Annotations) { triggers = append(triggers, StatefulSetAnnotationsChanged) update = true } - //// SpecResourcesChanged - //if !apiequality.Semantic.DeepEqual(sts.Spec.Template.Spec.Containers[0].Resources, ts.Spec.GetResources()) { - // triggers = append(triggers, SpecResourcesChanged) - // update = true - //} + // // SpecResourcesChanged + // if !apiequality.Semantic.DeepEqual(sts.Spec.Template.Spec.Containers[0].Resources, ts.Spec.GetResources()) { + // triggers = append(triggers, SpecResourcesChanged) + // update = true + // } // PodSecurityContextChanged if !apiequality.Semantic.DeepEqual(sts.Spec.Template.Spec.SecurityContext, ts.Spec.GetPodSecurityContext()) { diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 6686bce..6dbebf8 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -17,8 +17,22 @@ import ( ) const ( - letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" debugLevel = 1 + + labelManagedBy = "app.kubernetes.io/managed-by" + labelName = "app.kubernetes.io/name" + labelInstance = "app.kubernetes.io/instance" + managedByValue = "typesense-operator" + typesenseValue = "typesense" + appLabel = "app" + + httpPortName = "http" + envTypesenseApiKey = "TYPESENSE_API_KEY" + envTypesenseProtocol = "TYPESENSE_PROTOCOL" + healthcheckValue = "healthcheck" + nodesListValue = "nodeslist" + dataValue = "data" + nginxConfValue = "nginx.conf" ) func generateToken() (string, error) { @@ -32,19 +46,6 @@ func generateToken() (string, error) { return base64EncodedToken, nil } -func generateSecureRandomString(length int) (string, error) { - result := make([]byte, length) - _, err := rand.Read(result) - if err != nil { - return "", err - } - - for i := range result { - result[i] = letters[int(result[i])%len(letters)] - } - return string(result), nil -} - func mergeMaps(maps ...map[string]string) map[string]string { size := 0 for _, m := range maps { @@ -89,15 +90,15 @@ func getMergedLabels(def map[string]string, scoped map[string]string) map[string func getDefaultLabels(ts *tsv1alpha1.TypesenseCluster) map[string]string { return map[string]string{ - "app.kubernetes.io/managed-by": "typesense-operator", - "app.kubernetes.io/name": "typesense", - "app.kubernetes.io/instance": ts.Name, + labelManagedBy: managedByValue, + labelName: typesenseValue, + labelInstance: ts.Name, } } func getLabels(ts *tsv1alpha1.TypesenseCluster) map[string]string { return map[string]string{ - "app": fmt.Sprintf(ClusterAppLabel, ts.Name), + appLabel: fmt.Sprintf(ClusterAppLabel, ts.Name), } } @@ -116,7 +117,7 @@ func getObjectMeta(ts *tsv1alpha1.TypesenseCluster, name *string, annotations ma func getReverseProxyLabels(ts *tsv1alpha1.TypesenseCluster) map[string]string { return map[string]string{ - "app": fmt.Sprintf(ClusterReverseProxyAppLabel, ts.Name), + appLabel: fmt.Sprintf(ClusterReverseProxyAppLabel, ts.Name), } } @@ -135,7 +136,7 @@ func getReverseProxyObjectMeta(ts *tsv1alpha1.TypesenseCluster, name *string, an func getPodMonitorLabels(ts *tsv1alpha1.TypesenseCluster) map[string]string { return map[string]string{ - "app": fmt.Sprintf(ClusterMetricsPodMonitorAppLabel, ts.Name), + appLabel: fmt.Sprintf(ClusterMetricsPodMonitorAppLabel, ts.Name), } } @@ -169,8 +170,8 @@ func getPodMonitorObjectMeta(ts *tsv1alpha1.TypesenseCluster, name *string, anno func getHttpRouteLabels(ts *tsv1alpha1.TypesenseCluster, spec tsv1alpha1.HttpRouteSpec) map[string]string { route := map[string]string{ - "app": fmt.Sprintf(ClusterAppLabel, ts.Name), - "route": fmt.Sprintf(ClusterHttpRoute, ts.Name, spec.Name), + appLabel: fmt.Sprintf(ClusterAppLabel, ts.Name), + "route": fmt.Sprintf(ClusterHttpRoute, ts.Name, spec.Name), } defaults := getDefaultLabels(ts) @@ -199,32 +200,14 @@ func getReferenceGrantObjectMeta(ts *tsv1alpha1.TypesenseCluster, spec tsv1alpha } } -const ( - minDelayPerReplicaFactor = 1 - maxDelayPerReplicaFactor = 3 -) - -func getDelayPerReplicaFactor(size int) int64 { - if size != 0 { - if size <= maxDelayPerReplicaFactor { - return int64(size) - } else { - return maxDelayPerReplicaFactor - } - } - return minDelayPerReplicaFactor -} - -func contains(values []string, value string) (int, bool) { - //sort.Strings(values) - - for i, v := range values { +func contains(values []string, value string) bool { + for _, v := range values { if v == value { - return i, true + return true } } - return -1, false + return false } func normalizeVolumes(vols []corev1.Volume) []corev1.Volume { @@ -234,7 +217,7 @@ func normalizeVolumes(vols []corev1.Volume) []corev1.Volume { vcopy := append([]corev1.Volume(nil), vols...) for i := range vcopy { - if cm := vcopy[i].VolumeSource.ConfigMap; cm != nil { + if cm := vcopy[i].ConfigMap; cm != nil { cm.DefaultMode = nil } } diff --git a/test/utils/utils.go b/test/utils/utils.go index 6b96ab5..38a95c7 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -22,7 +22,7 @@ import ( "os/exec" "strings" - . "github.com/onsi/ginkgo/v2" //nolint:golint,revive + "github.com/onsi/ginkgo/v2" ) const ( @@ -35,7 +35,7 @@ const ( ) func warnError(err error) { - _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "warning: %v\n", err) } // InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. @@ -52,12 +52,12 @@ func Run(cmd *exec.Cmd) ([]byte, error) { cmd.Dir = dir if err := os.Chdir(cmd.Dir); err != nil { - _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "chdir dir: %s\n", err) } cmd.Env = append(os.Environ(), "GO111MODULE=on") command := strings.Join(cmd.Args, " ") - _, _ = fmt.Fprintf(GinkgoWriter, "running: %s\n", command) + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "running: %s\n", command) output, err := cmd.CombinedOutput() if err != nil { return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) @@ -135,6 +135,6 @@ func GetProjectDir() (string, error) { if err != nil { return wd, err } - wd = strings.Replace(wd, "/test/e2e", "", -1) + wd = strings.ReplaceAll(wd, "/test/e2e", "") return wd, nil } From fd76a4d1acbf6e7a96e50ab06f25499b4cf8885d Mon Sep 17 00:00:00 2001 From: "renovate-interworks[bot]" <309859563+renovate-interworks[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:57:27 +0000 Subject: [PATCH 16/19] chore(deps): update dependency kubectl to v1.36.4 --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 65cfb62..e5476f6 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] kind = "0.32.0" -kubectl = "1.36.2" +kubectl = "1.36.4" helm = "4.2.4" golangci-lint = "2.13.1" From 79227e25b28cae4138f76a166d19d9e86528e3fa Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Mon, 31 Aug 2026 08:18:35 -0400 Subject: [PATCH 17/19] ci: add go dep to mise --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index e5476f6..80f3513 100644 --- a/mise.toml +++ b/mise.toml @@ -3,3 +3,4 @@ kind = "0.32.0" kubectl = "1.36.4" helm = "4.2.4" golangci-lint = "2.13.1" +go = "1.24.0" From 9c45c581c76d831255608c7eddd871e1d7ebeac5 Mon Sep 17 00:00:00 2001 From: Henry Arend Date: Mon, 31 Aug 2026 09:38:11 -0400 Subject: [PATCH 18/19] ci: update to use mise to install tooling (#23) --- .github/workflows/lint.yml | 6 ++---- .github/workflows/releases.yaml | 3 +++ .github/workflows/test-e2e.yml | 17 ++++++----------- .github/workflows/test.yml | 6 ++---- mise.toml | 3 ++- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1afa0f1..2ea061c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,10 +15,8 @@ jobs: - name: Clone the code uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - name: Set up tools via mise + uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 - name: Run linter uses: golangci/golangci-lint-action@v8 diff --git a/.github/workflows/releases.yaml b/.github/workflows/releases.yaml index f33e2cd..39dcb5e 100644 --- a/.github/workflows/releases.yaml +++ b/.github/workflows/releases.yaml @@ -26,6 +26,9 @@ jobs: git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + - name: Install Deps + uses: jdx/mise-action@v4.3.0 + - name: Run chart-releaser uses: helm/chart-releaser-action@v1.6.0 env: diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 4a03ad0..f8052dd 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -2,25 +2,20 @@ name: E2E Tests on: workflow_dispatch: + pull_request: + types: [ labeled ] jobs: test-e2e: - name: Run on Ubuntu + name: Run End to End Tests + if: ${{ github.event.label.name == 'e2e' }} runs-on: ubuntu-latest steps: - name: Clone the code uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Install the latest version of kind - run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + - name: Set up tools via mise + uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 - name: Verify kind installation run: kind version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92da8d9..fbf3dac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,8 @@ jobs: - name: Clone the code uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - name: Set up tools via mise + uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 - name: Running Tests run: | diff --git a/mise.toml b/mise.toml index 80f3513..42f6c61 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,7 @@ [tools] -kind = "0.32.0" +kind = "0.33.0" kubectl = "1.36.4" helm = "4.2.4" golangci-lint = "2.13.1" go = "1.24.0" +"aqua:helm/chart-releaser" = "1.8.1" From d6cd4b91d11541b443732ff8f4764a153de40402 Mon Sep 17 00:00:00 2001 From: Kyriakos Akriotis Date: Wed, 26 Aug 2026 09:12:54 +0200 Subject: [PATCH 19/19] 215 use ldflags for internal versioning (#276) * injecting version, commit hash, build date to the binaries #215 * added ldflags in makefile and tests #215 --- Dockerfile | 13 ++++++- Makefile | 21 +++++++++-- cmd/main.go | 5 ++- internal/version/version.go | 70 +++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 internal/version/version.go diff --git a/Dockerfile b/Dockerfile index ca05a79..cc0b595 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,10 @@ FROM golang:1.24 AS builder ARG TARGETOS ARG TARGETARCH +ARG BIN_VERSION=dev +ARG BIN_GIT_COMMIT=unknown +ARG BIN_BUILD_DATE=unknown + WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod @@ -15,13 +19,20 @@ RUN go mod download COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/controller/ internal/controller/ +COPY internal/version/ internal/version/ # Build # the GOARCH has not a default value to allow the binary be built according to the host where the command # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ + go build -a \ + -ldflags "-s -w \ + -X github.com/akyriako/typesense-operator/internal/version.Version=${BIN_VERSION} \ + -X github.com/akyriako/typesense-operator/internal/version.Commit=${BIN_GIT_COMMIT} \ + -X github.com/akyriako/typesense-operator/internal/version.BuildDate=${BIN_BUILD_DATE}" \ + -o manager ./cmd/main.go # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/Makefile b/Makefile index 734bf89..7b8b88d 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,21 @@ IMG ?= $(DOCKER_HUB_NAME)/$(IMG_NAME):$(IMG_TAG) # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION := $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') +#LDFLAGS +BIN_VERSION ?= $(IMG_TAG) +BIN_GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +BIN_BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +LDFLAGS = -s -w \ + -X github.com/akyriako/typesense-operator/internal/version.Version=$(BIN_VERSION) \ + -X github.com/akyriako/typesense-operator/internal/version.Commit=$(BIN_GIT_COMMIT) \ + -X github.com/akyriako/typesense-operator/internal/version.BuildDate=$(BIN_BUILD_DATE) + +DOCKER_BUILD_ARGS := \ + --build-arg BIN_VERSION=$(BIN_VERSION) \ + --build-arg BIN_GIT_COMMIT=$(BIN_GIT_COMMIT) \ + --build-arg BIN_BUILD_DATE=$(BIN_BUILD_DATE) + # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) GOBIN=$(shell go env GOPATH)/bin @@ -164,7 +179,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go + go build -ldflags "$(LDFLAGS)" -o bin/manager cmd/main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. @@ -175,7 +190,7 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . + $(CONTAINER_TOOL) build $(DOCKER_BUILD_ARGS) -t ${IMG} . .PHONY: docker-push docker-push: ## Push docker image with the manager. @@ -194,7 +209,7 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name typesense-operator-builder $(CONTAINER_TOOL) buildx use typesense-operator-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) $(DOCKER_BUILD_ARGS) --tag ${IMG} -f Dockerfile.cross . - $(CONTAINER_TOOL) buildx rm typesense-operator-builder rm Dockerfile.cross diff --git a/cmd/main.go b/cmd/main.go index 307c817..799503c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,11 +19,13 @@ package main import ( "crypto/tls" "flag" + "fmt" "net/http" "os" "path/filepath" "time" + "github.com/akyriako/typesense-operator/internal/version" "go.uber.org/zap/zapcore" "k8s.io/client-go/kubernetes" "sigs.k8s.io/controller-runtime/pkg/certwatcher" @@ -286,7 +288,8 @@ func main() { os.Exit(1) } - setupLog.Info("starting manager") + v := version.GetBuildInfo() + setupLog.Info("starting manager", "version", fmt.Sprintf("%s+%s", v.Version, v.Commit), "buildDate", v.BuildDate, "goVersion", v.GoVersion) if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..2013b2a --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,70 @@ +package version + +import ( + "runtime/debug" +) + +const ( + defaultVersion = "dev" + defaultCommit = "none" + defaultBuildDate = "unknown" + defaultValue = "unknown" +) + +var ( + Version = defaultVersion + Commit = defaultCommit + BuildDate = defaultBuildDate +) + +type VersionInfo struct { + GoVersion string `json:"goVersion"` + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"buildDate"` +} + +func GetBuildInfo() VersionInfo { + // Prefer ldflags-injected values if they were set + if Version != defaultVersion || Commit != defaultCommit || BuildDate != defaultBuildDate { + binfo, ok := debug.ReadBuildInfo() + goVer := defaultValue + if ok { + goVer = binfo.GoVersion + } + return VersionInfo{ + GoVersion: goVer, + Version: Version, + Commit: Commit, + BuildDate: BuildDate, + } + } + + // Fallback: ReadBuildInfo + vcs.* + binfo, ok := debug.ReadBuildInfo() + if !ok { + return VersionInfo{ + GoVersion: defaultValue, + Version: defaultVersion, + Commit: defaultCommit, + BuildDate: defaultBuildDate, + } + } + + return VersionInfo{ + GoVersion: binfo.GoVersion, + Version: binfo.Main.Version, + Commit: getBuildInfoSetting(binfo, "vcs.revision"), + BuildDate: getBuildInfoSetting(binfo, "vcs.time"), + } +} + +func getBuildInfoSetting(info *debug.BuildInfo, key string) string { + for _, s := range info.Settings { + if s.Key == key { + return s.Value + } + } + + return defaultValue +}