From b27d01217e5f4336996f66f1f4296eb077c9e433 Mon Sep 17 00:00:00 2001 From: nghiadaulau Date: Sat, 20 Jun 2026 12:58:17 +0700 Subject: [PATCH] feat(operator): VersusIncident operator (CRD + controller) A controller-runtime operator (group ops.versuscontrol.io) that reconciles a VersusIncident custom resource into a ConfigMap + Deployment + Service with owner references (garbage collection) and status reporting. The CR exposes a provider enum (openai/gemini) and a full sources list (file/loki/ elasticsearch). Lives in its own nested Go module under operator/ so the app module is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- operator/.gitignore | 5 + operator/Dockerfile | 17 + operator/Makefile | 81 ++++ operator/PROJECT | 16 + operator/README.md | 99 ++++ operator/api/v1alpha1/groupversion_info.go | 24 + operator/api/v1alpha1/versusincident_types.go | 169 +++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 262 +++++++++++ operator/cmd/main.go | 75 +++ .../ops.versuscontrol.io_versusincidents.yaml | 305 +++++++++++++ operator/config/manager/manager.yaml | 64 +++ operator/config/rbac/role.yaml | 57 +++ operator/config/rbac/role_binding.yaml | 14 + operator/config/rbac/service_account.yaml | 7 + .../samples/ops_v1alpha1_versusincident.yaml | 35 ++ operator/go.mod | 67 +++ operator/go.sum | 194 ++++++++ .../controller/versusincident_controller.go | 429 ++++++++++++++++++ 18 files changed, 1920 insertions(+) create mode 100644 operator/.gitignore create mode 100644 operator/Dockerfile create mode 100644 operator/Makefile create mode 100644 operator/PROJECT create mode 100644 operator/README.md create mode 100644 operator/api/v1alpha1/groupversion_info.go create mode 100644 operator/api/v1alpha1/versusincident_types.go create mode 100644 operator/api/v1alpha1/zz_generated.deepcopy.go create mode 100644 operator/cmd/main.go create mode 100644 operator/config/crd/bases/ops.versuscontrol.io_versusincidents.yaml create mode 100644 operator/config/manager/manager.yaml create mode 100644 operator/config/rbac/role.yaml create mode 100644 operator/config/rbac/role_binding.yaml create mode 100644 operator/config/rbac/service_account.yaml create mode 100644 operator/config/samples/ops_v1alpha1_versusincident.yaml create mode 100644 operator/go.mod create mode 100644 operator/go.sum create mode 100644 operator/internal/controller/versusincident_controller.go diff --git a/operator/.gitignore b/operator/.gitignore new file mode 100644 index 0000000..d7aa269 --- /dev/null +++ b/operator/.gitignore @@ -0,0 +1,5 @@ +bin/ +*.test +*.o +cover.out +cover.html diff --git a/operator/Dockerfile b/operator/Dockerfile new file mode 100644 index 0000000..5869001 --- /dev/null +++ b/operator/Dockerfile @@ -0,0 +1,17 @@ +# Build the manager binary +FROM golang:1.25-alpine AS build +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ cmd/ +COPY api/ api/ +COPY internal/ internal/ +RUN CGO_ENABLED=0 GOOS=linux go build -o manager ./cmd + +# Minimal runtime image. Static binary (CGO disabled) runs on alpine; the +# image runs as an unprivileged user to satisfy runAsNonRoot. +FROM alpine:3.20 +WORKDIR / +COPY --from=build /workspace/manager . +USER 65532:65532 +ENTRYPOINT ["/manager"] diff --git a/operator/Makefile b/operator/Makefile new file mode 100644 index 0000000..8ee6a60 --- /dev/null +++ b/operator/Makefile @@ -0,0 +1,81 @@ +# Image URL to use all building/pushing image targets +IMG ?= versus-incident-operator:local +CONTROLLER_GEN ?= $(shell go env GOPATH)/bin/controller-gen + +.PHONY: all +all: build + +##@ Code generation + +.PHONY: manifests +manifests: ## Generate CRD and RBAC manifests from markers. + $(CONTROLLER_GEN) crd paths=./api/... output:crd:artifacts:config=config/crd/bases + $(CONTROLLER_GEN) rbac:roleName=manager-role paths=./internal/... output:rbac:artifacts:config=config/rbac + +.PHONY: generate +generate: ## Generate DeepCopy methods. + $(CONTROLLER_GEN) object paths=./api/... + +##@ Development (required standard targets) + +.PHONY: install +install: ## Install the CRD into the cluster. + kubectl apply -f config/crd/bases + +.PHONY: dev +dev: generate ## Run the controller locally against the current kube context. + go run ./cmd + +.PHONY: test +test: generate ## Run unit tests. + go test ./... -count=1 + +.PHONY: lint +lint: ## Static analysis (go vet). + go vet ./... + +.PHONY: format +format: ## Format the code. + go fmt ./... + +.PHONY: typecheck +typecheck: ## Compile-time type check (no output binary). + go build -o /dev/null ./... + +.PHONY: clean +clean: ## Remove build artifacts. + rm -rf bin + +##@ Build + +.PHONY: build +build: generate format lint ## Build the manager binary. + go build -o bin/manager ./cmd + +.PHONY: docker-build +docker-build: ## Build the manager image ($(IMG)). + docker build -t $(IMG) . + +##@ Cluster lifecycle (up/down = deploy/teardown of the operator) + +.PHONY: deploy +deploy: ## Deploy CRD + RBAC + manager to the cluster. + kubectl apply -f config/crd/bases + kubectl apply -f config/rbac/role.yaml + kubectl apply -f config/rbac/service_account.yaml + kubectl apply -f config/rbac/role_binding.yaml + kubectl apply -f config/manager/manager.yaml + +.PHONY: undeploy +undeploy: ## Tear the controller down. + -kubectl delete -f config/manager/manager.yaml + -kubectl delete -f config/rbac/role_binding.yaml + -kubectl delete -f config/rbac/service_account.yaml + -kubectl delete -f config/rbac/role.yaml + -kubectl delete -f config/crd/bases + +.PHONY: docker-up +docker-up: deploy ## Bring the operator up in the cluster (alias: deploy). + +.PHONY: docker-down +docker-down: undeploy ## Take the operator down from the cluster (alias: undeploy). diff --git a/operator/PROJECT b/operator/PROJECT new file mode 100644 index 0000000..9ca74d1 --- /dev/null +++ b/operator/PROJECT @@ -0,0 +1,16 @@ +domain: versuscontrol.io +layout: +- go.kubebuilder.io/v4 +projectName: versus-incident-operator +repo: github.com/VersusControl/versus-incident/operator +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: versuscontrol.io + group: ops + kind: VersusIncident + path: github.com/VersusControl/versus-incident/operator/api/v1alpha1 + version: v1alpha1 +version: "3" diff --git a/operator/README.md b/operator/README.md new file mode 100644 index 0000000..a1d6038 --- /dev/null +++ b/operator/README.md @@ -0,0 +1,99 @@ +# Versus Incident Operator + +A Kubernetes operator (kubebuilder/controller-runtime, group `ops.versuscontrol.io`) +that manages Versus Incident deployments declaratively. One `VersusIncident` +custom resource is reconciled into a **ConfigMap + Deployment + Service**, with +owner references so deleting the CR garbage-collects everything. + +It is an alternative to the Helm chart: same app, but driven by a CR the +controller continuously reconciles (self-healing, status reporting) instead of +a one-shot `helm install`. + +## Layout (standard kubebuilder) + +``` +operator/ +├── api/v1alpha1/ # VersusIncident types + generated DeepCopy +├── internal/controller/ # the reconciler +├── cmd/main.go # manager entrypoint +├── config/ +│ ├── crd/bases/ # generated CRD +│ ├── rbac/ # generated ClusterRole + SA + binding +│ ├── manager/ # operator Deployment + Namespace +│ └── samples/ # sample VersusIncident CR +├── Dockerfile Makefile PROJECT +``` + +## Quick start (minikube) + +```bash +# 0) build the operator image into minikube's docker daemon +eval $(minikube docker-env) +make -C operator docker-build # → versus-incident-operator:local + +# 1) install CRD + RBAC + run the manager in-cluster +make -C operator deploy + +# 2) create the app namespace + the secret the CR references +kubectl create namespace versus +kubectl -n versus create secret generic versus-operator-secrets \ + --from-literal=gateway_secret="$(openssl rand -hex 32)" \ + --from-literal=telegram_bot_token='' \ + --from-literal=telegram_chat_id='' \ + --from-literal=agent_ai_api_key='' + +# 3) create a VersusIncident — the operator builds the workload +kubectl apply -f operator/config/samples/ops_v1alpha1_versusincident.yaml + +# 4) observe +kubectl get versusincident -n versus # short name: vi +kubectl get deploy,svc,cm -n versus -l app.kubernetes.io/managed-by=versus-incident-operator +``` + +Deleting the CR removes the Deployment/Service/ConfigMap automatically: + +```bash +kubectl delete vi demo -n versus +``` + +## CRD shape + +```yaml +apiVersion: ops.versuscontrol.io/v1alpha1 +kind: VersusIncident +spec: + image: { repository, tag, pullPolicy } + replicas: 1 + gatewaySecretName: + telegram: + enabled: true + secretName: + agent: + enable: true + mode: detect # training | shadow | detect + pollInterval: 15s + ai: + enable: true + provider: gemini # openai | gemini (maps to the endpoint internally) + model: gemini-2.5-flash-lite + apiKeySecretName: + sources: # mirrors agent_sources.yaml + - name: demo-app + type: file # file | loki | elasticsearch + enable: true + file: { path: /app/data/app.log, fromBeginning: true } +status: + readyReplicas: + conditions: [ { type: Ready, ... } ] +``` + +Secrets are **referenced, never embedded** in the CR. The rendered in-pod +`config.yaml` includes the full agent detection config (regex / redaction / +miner / catalog / service_patterns) so the agent matches out of the box. + +## Regenerate after API changes + +```bash +make -C operator manifests generate # CRD + RBAC + DeepCopy +make -C operator build +``` diff --git a/operator/api/v1alpha1/groupversion_info.go b/operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..f6f6fb1 --- /dev/null +++ b/operator/api/v1alpha1/groupversion_info.go @@ -0,0 +1,24 @@ +// Package v1alpha1 contains API Schema definitions for the ops v1alpha1 API +// group. The single kind, VersusIncident, declaratively describes one Versus +// Incident deployment; the controller reconciles it into a ConfigMap, +// Deployment and Service. +// +// +kubebuilder:object:generate=true +// +groupName=ops.versuscontrol.io +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "ops.versuscontrol.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/operator/api/v1alpha1/versusincident_types.go b/operator/api/v1alpha1/versusincident_types.go new file mode 100644 index 0000000..173f8c2 --- /dev/null +++ b/operator/api/v1alpha1/versusincident_types.go @@ -0,0 +1,169 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ImageSpec selects the container image for the managed Deployment. +type ImageSpec struct { + // Repository is the image repository, e.g. "ghcr.io/versuscontrol/versus-incident". + // +kubebuilder:default="ghcr.io/versuscontrol/versus-incident" + Repository string `json:"repository,omitempty"` + // Tag is the image tag. Defaults to a recent published tag. + // +kubebuilder:default="v1.4.3" + Tag string `json:"tag,omitempty"` + // PullPolicy is the image pull policy. + // +kubebuilder:validation:Enum=Always;IfNotPresent;Never + // +kubebuilder:default=IfNotPresent + PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"` +} + +// TelegramSpec configures the Telegram alert channel. +type TelegramSpec struct { + // Enabled turns the Telegram channel on. + Enabled bool `json:"enabled,omitempty"` + // SecretName references a Secret holding keys `telegram_bot_token` and + // `telegram_chat_id`. Required when Enabled is true. + SecretName string `json:"secretName,omitempty"` +} + +// AgentAISpec points the agent's chat model at an OpenAI-compatible endpoint. +type AgentAISpec struct { + // Enable turns the LLM analyzer on (required for detect mode to call a model). + Enable bool `json:"enable,omitempty"` + // Provider selects the LLM backend: "openai" (default) or "gemini". It + // maps to the right OpenAI-compatible endpoint internally — no base URL. + // +kubebuilder:validation:Enum=openai;gemini + // +kubebuilder:default=openai + Provider string `json:"provider,omitempty"` + // Model is the model identifier, e.g. "gemini-2.5-flash-lite". + // +kubebuilder:default="gpt-4o-mini" + Model string `json:"model,omitempty"` + // APIKeySecretName references a Secret holding key `agent_ai_api_key`. + APIKeySecretName string `json:"apiKeySecretName,omitempty"` +} + +// AgentSpec configures the AI SRE agent loop. +type AgentSpec struct { + // Enable turns the agent on. + Enable bool `json:"enable,omitempty"` + // Mode is the agent operating mode. + // +kubebuilder:validation:Enum=training;shadow;detect + // +kubebuilder:default=training + Mode string `json:"mode,omitempty"` + // PollInterval is how often each source is pulled (Go duration). + // +kubebuilder:default="15s" + PollInterval string `json:"pollInterval,omitempty"` + // AI configures the LLM analyzer. + AI AgentAISpec `json:"ai,omitempty"` + // Sources is the list of signal sources the agent tails, mirroring + // agent_sources.yaml. Empty leaves the agent with no sources. + Sources []SourceSpec `json:"sources,omitempty"` +} + +// SourceSpec is one agent signal source (mirrors an agent_sources.yaml entry). +type SourceSpec struct { + // Name is a unique source name. + Name string `json:"name"` + // Type selects the source kind. + // +kubebuilder:validation:Enum=file;loki;elasticsearch + Type string `json:"type"` + // Enable turns this source on. + Enable bool `json:"enable,omitempty"` + // File configures a file-tailing source (type=file). + File *FileSourceSpec `json:"file,omitempty"` + // Loki configures a Grafana Loki source (type=loki). + Loki *LokiSourceSpec `json:"loki,omitempty"` + // Elasticsearch configures an Elasticsearch source (type=elasticsearch). + Elasticsearch *ElasticsearchSourceSpec `json:"elasticsearch,omitempty"` +} + +// FileSourceSpec tails a log file inside the pod. +type FileSourceSpec struct { + // Path to the log file. + Path string `json:"path"` + // FromBeginning replays the whole file from offset 0 instead of tailing. + FromBeginning bool `json:"fromBeginning,omitempty"` + // Format is "text" (default) or "json". + Format string `json:"format,omitempty"` +} + +// LokiSourceSpec reads from a Grafana Loki instance. +type LokiSourceSpec struct { + // Address is the Loki base URL, e.g. http://loki:3100. + Address string `json:"address"` + // Query is a LogQL selector, e.g. {app="api"} |= "error". + Query string `json:"query"` + // TenantID sets X-Scope-OrgID for multi-tenant Loki. + TenantID string `json:"tenantID,omitempty"` +} + +// ElasticsearchSourceSpec reads from an Elasticsearch cluster. +type ElasticsearchSourceSpec struct { + // Addresses is the list of node URLs. + Addresses []string `json:"addresses"` + // Index is the index name or pattern. + Index string `json:"index"` + // Query is a Lucene query string. + Query string `json:"query,omitempty"` +} + +// VersusIncidentSpec is the desired state of a Versus Incident deployment. +type VersusIncidentSpec struct { + // Image selects the container image. + Image ImageSpec `json:"image,omitempty"` + // Replicas is the desired pod count. Keep at 1 when the agent is enabled + // (the agent worker is single-writer to the catalog/detect log). + // +kubebuilder:default=1 + // +kubebuilder:validation:Minimum=0 + Replicas *int32 `json:"replicas,omitempty"` + // GatewaySecretName references a Secret holding key `gateway_secret` + // (gates /api/admin/* and /api/agent/*). Required when the agent is enabled. + GatewaySecretName string `json:"gatewaySecretName,omitempty"` + // Telegram configures the Telegram channel. + Telegram TelegramSpec `json:"telegram,omitempty"` + // Agent configures the AI SRE agent. + Agent AgentSpec `json:"agent,omitempty"` +} + +// VersusIncidentStatus is the observed state of a Versus Incident deployment. +type VersusIncidentStatus struct { + // ReadyReplicas mirrors the managed Deployment's ready replica count. + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + // ObservedGeneration is the .metadata.generation last reconciled. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Conditions represent the latest observations of the resource's state. + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=vi +// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.agent.mode` +// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// VersusIncident is the Schema for the versusincidents API. +type VersusIncident struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec VersusIncidentSpec `json:"spec,omitempty"` + Status VersusIncidentStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// VersusIncidentList contains a list of VersusIncident. +type VersusIncidentList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []VersusIncident `json:"items"` +} + +func init() { + SchemeBuilder.Register(&VersusIncident{}, &VersusIncidentList{}) +} diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..c211e3f --- /dev/null +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,262 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentAISpec) DeepCopyInto(out *AgentAISpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentAISpec. +func (in *AgentAISpec) DeepCopy() *AgentAISpec { + if in == nil { + return nil + } + out := new(AgentAISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { + *out = *in + out.AI = in.AI + if in.Sources != nil { + in, out := &in.Sources, &out.Sources + *out = make([]SourceSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. +func (in *AgentSpec) DeepCopy() *AgentSpec { + if in == nil { + return nil + } + out := new(AgentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ElasticsearchSourceSpec) DeepCopyInto(out *ElasticsearchSourceSpec) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ElasticsearchSourceSpec. +func (in *ElasticsearchSourceSpec) DeepCopy() *ElasticsearchSourceSpec { + if in == nil { + return nil + } + out := new(ElasticsearchSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileSourceSpec) DeepCopyInto(out *FileSourceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSourceSpec. +func (in *FileSourceSpec) DeepCopy() *FileSourceSpec { + if in == nil { + return nil + } + out := new(FileSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageSpec) DeepCopyInto(out *ImageSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageSpec. +func (in *ImageSpec) DeepCopy() *ImageSpec { + if in == nil { + return nil + } + out := new(ImageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LokiSourceSpec) DeepCopyInto(out *LokiSourceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LokiSourceSpec. +func (in *LokiSourceSpec) DeepCopy() *LokiSourceSpec { + if in == nil { + return nil + } + out := new(LokiSourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SourceSpec) DeepCopyInto(out *SourceSpec) { + *out = *in + if in.File != nil { + in, out := &in.File, &out.File + *out = new(FileSourceSpec) + **out = **in + } + if in.Loki != nil { + in, out := &in.Loki, &out.Loki + *out = new(LokiSourceSpec) + **out = **in + } + if in.Elasticsearch != nil { + in, out := &in.Elasticsearch, &out.Elasticsearch + *out = new(ElasticsearchSourceSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceSpec. +func (in *SourceSpec) DeepCopy() *SourceSpec { + if in == nil { + return nil + } + out := new(SourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TelegramSpec) DeepCopyInto(out *TelegramSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TelegramSpec. +func (in *TelegramSpec) DeepCopy() *TelegramSpec { + if in == nil { + return nil + } + out := new(TelegramSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersusIncident) DeepCopyInto(out *VersusIncident) { + *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 VersusIncident. +func (in *VersusIncident) DeepCopy() *VersusIncident { + if in == nil { + return nil + } + out := new(VersusIncident) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VersusIncident) 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 *VersusIncidentList) DeepCopyInto(out *VersusIncidentList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]VersusIncident, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersusIncidentList. +func (in *VersusIncidentList) DeepCopy() *VersusIncidentList { + if in == nil { + return nil + } + out := new(VersusIncidentList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *VersusIncidentList) 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 *VersusIncidentSpec) DeepCopyInto(out *VersusIncidentSpec) { + *out = *in + out.Image = in.Image + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + out.Telegram = in.Telegram + in.Agent.DeepCopyInto(&out.Agent) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersusIncidentSpec. +func (in *VersusIncidentSpec) DeepCopy() *VersusIncidentSpec { + if in == nil { + return nil + } + out := new(VersusIncidentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersusIncidentStatus) DeepCopyInto(out *VersusIncidentStatus) { + *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]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersusIncidentStatus. +func (in *VersusIncidentStatus) DeepCopy() *VersusIncidentStatus { + if in == nil { + return nil + } + out := new(VersusIncidentStatus) + in.DeepCopyInto(out) + return out +} diff --git a/operator/cmd/main.go b/operator/cmd/main.go new file mode 100644 index 0000000..3927096 --- /dev/null +++ b/operator/cmd/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + opsv1alpha1 "github.com/VersusControl/versus-incident/operator/api/v1alpha1" + "github.com/VersusControl/versus-incident/operator/internal/controller" +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(opsv1alpha1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr, probeAddr string + var enableLeaderElection bool + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "versus-incident-operator.versuscontrol.io", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + if err = (&controller.VersusIncidentReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "VersusIncident") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/operator/config/crd/bases/ops.versuscontrol.io_versusincidents.yaml b/operator/config/crd/bases/ops.versuscontrol.io_versusincidents.yaml new file mode 100644 index 0000000..2d28d32 --- /dev/null +++ b/operator/config/crd/bases/ops.versuscontrol.io_versusincidents.yaml @@ -0,0 +1,305 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + name: versusincidents.ops.versuscontrol.io +spec: + group: ops.versuscontrol.io + names: + kind: VersusIncident + listKind: VersusIncidentList + plural: versusincidents + shortNames: + - vi + singular: versusincident + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.agent.mode + name: Mode + type: string + - jsonPath: .status.readyReplicas + name: Ready + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: VersusIncident is the Schema for the versusincidents 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: VersusIncidentSpec is the desired state of a Versus Incident + deployment. + properties: + agent: + description: Agent configures the AI SRE agent. + properties: + ai: + description: AI configures the LLM analyzer. + properties: + apiKeySecretName: + description: APIKeySecretName references a Secret holding + key `agent_ai_api_key`. + type: string + enable: + description: Enable turns the LLM analyzer on (required for + detect mode to call a model). + type: boolean + model: + default: gpt-4o-mini + description: Model is the model identifier, e.g. "gemini-2.5-flash-lite". + type: string + provider: + default: openai + description: |- + Provider selects the LLM backend: "openai" (default) or "gemini". It + maps to the right OpenAI-compatible endpoint internally — no base URL. + enum: + - openai + - gemini + type: string + type: object + enable: + description: Enable turns the agent on. + type: boolean + mode: + default: training + description: Mode is the agent operating mode. + enum: + - training + - shadow + - detect + type: string + pollInterval: + default: 15s + description: PollInterval is how often each source is pulled (Go + duration). + type: string + sources: + description: |- + Sources is the list of signal sources the agent tails, mirroring + agent_sources.yaml. Empty leaves the agent with no sources. + items: + description: SourceSpec is one agent signal source (mirrors + an agent_sources.yaml entry). + properties: + elasticsearch: + description: Elasticsearch configures an Elasticsearch source + (type=elasticsearch). + properties: + addresses: + description: Addresses is the list of node URLs. + items: + type: string + type: array + index: + description: Index is the index name or pattern. + type: string + query: + description: Query is a Lucene query string. + type: string + required: + - addresses + - index + type: object + enable: + description: Enable turns this source on. + type: boolean + file: + description: File configures a file-tailing source (type=file). + properties: + format: + description: Format is "text" (default) or "json". + type: string + fromBeginning: + description: FromBeginning replays the whole file from + offset 0 instead of tailing. + type: boolean + path: + description: Path to the log file. + type: string + required: + - path + type: object + loki: + description: Loki configures a Grafana Loki source (type=loki). + properties: + address: + description: Address is the Loki base URL, e.g. http://loki:3100. + type: string + query: + description: Query is a LogQL selector, e.g. {app="api"} + |= "error". + type: string + tenantID: + description: TenantID sets X-Scope-OrgID for multi-tenant + Loki. + type: string + required: + - address + - query + type: object + name: + description: Name is a unique source name. + type: string + type: + description: Type selects the source kind. + enum: + - file + - loki + - elasticsearch + type: string + required: + - name + - type + type: object + type: array + type: object + gatewaySecretName: + description: |- + GatewaySecretName references a Secret holding key `gateway_secret` + (gates /api/admin/* and /api/agent/*). Required when the agent is enabled. + type: string + image: + description: Image selects the container image. + properties: + pullPolicy: + default: IfNotPresent + description: PullPolicy is the image pull policy. + enum: + - Always + - IfNotPresent + - Never + type: string + repository: + default: ghcr.io/versuscontrol/versus-incident + description: Repository is the image repository, e.g. "ghcr.io/versuscontrol/versus-incident". + type: string + tag: + default: v1.4.3 + description: Tag is the image tag. Defaults to a recent published + tag. + type: string + type: object + replicas: + default: 1 + description: |- + Replicas is the desired pod count. Keep at 1 when the agent is enabled + (the agent worker is single-writer to the catalog/detect log). + format: int32 + minimum: 0 + type: integer + telegram: + description: Telegram configures the Telegram channel. + properties: + enabled: + description: Enabled turns the Telegram channel on. + type: boolean + secretName: + description: |- + SecretName references a Secret holding keys `telegram_bot_token` and + `telegram_chat_id`. Required when Enabled is true. + type: string + type: object + type: object + status: + description: VersusIncidentStatus is the observed state of a Versus Incident + deployment. + properties: + conditions: + description: Conditions represent the latest observations of the resource's + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + description: ObservedGeneration is the .metadata.generation last reconciled. + format: int64 + type: integer + readyReplicas: + description: ReadyReplicas mirrors the managed Deployment's ready + replica count. + format: int32 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/operator/config/manager/manager.yaml b/operator/config/manager/manager.yaml new file mode 100644 index 0000000..ff8117d --- /dev/null +++ b/operator/config/manager/manager.yaml @@ -0,0 +1,64 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: versus-operator-system + labels: + app.kubernetes.io/name: versus-incident-operator +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: versus-operator-controller-manager + namespace: versus-operator-system + labels: + app.kubernetes.io/name: versus-incident-operator + control-plane: controller-manager +spec: + replicas: 1 + selector: + matchLabels: + control-plane: controller-manager + template: + metadata: + labels: + control-plane: controller-manager + spec: + serviceAccountName: versus-operator-controller-manager + securityContext: + runAsNonRoot: true + runAsUser: 65532 + containers: + - name: manager + image: versus-incident-operator:local + imagePullPolicy: IfNotPresent + args: + - --health-probe-bind-address=:8081 + - --metrics-bind-address=0 + ports: + - name: health + containerPort: 8081 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi + terminationGracePeriodSeconds: 10 diff --git a/operator/config/rbac/role.yaml b/operator/config/rbac/role.yaml new file mode 100644 index 0000000..8ed3d74 --- /dev/null +++ b/operator/config/rbac/role.yaml @@ -0,0 +1,57 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - "" + resources: + - configmaps + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ops.versuscontrol.io + resources: + - versusincidents + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - ops.versuscontrol.io + resources: + - versusincidents/finalizers + verbs: + - update +- apiGroups: + - ops.versuscontrol.io + resources: + - versusincidents/status + verbs: + - get + - patch + - update diff --git a/operator/config/rbac/role_binding.yaml b/operator/config/rbac/role_binding.yaml new file mode 100644 index 0000000..fe3bd0d --- /dev/null +++ b/operator/config/rbac/role_binding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: versus-operator-manager-rolebinding + labels: + app.kubernetes.io/name: versus-incident-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: + - kind: ServiceAccount + name: versus-operator-controller-manager + namespace: versus-operator-system diff --git a/operator/config/rbac/service_account.yaml b/operator/config/rbac/service_account.yaml new file mode 100644 index 0000000..d182117 --- /dev/null +++ b/operator/config/rbac/service_account.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: versus-operator-controller-manager + namespace: versus-operator-system + labels: + app.kubernetes.io/name: versus-incident-operator diff --git a/operator/config/samples/ops_v1alpha1_versusincident.yaml b/operator/config/samples/ops_v1alpha1_versusincident.yaml new file mode 100644 index 0000000..6ca3e8e --- /dev/null +++ b/operator/config/samples/ops_v1alpha1_versusincident.yaml @@ -0,0 +1,35 @@ +# Sample VersusIncident. The referenced Secret (versus-operator-secrets in +# the SAME namespace) must hold: gateway_secret, telegram_bot_token, +# telegram_chat_id, agent_ai_api_key. See operator/README.md for the +# `kubectl create secret` command. +apiVersion: ops.versuscontrol.io/v1alpha1 +kind: VersusIncident +metadata: + name: demo + namespace: versus +spec: + image: + repository: versus-incident + tag: local + pullPolicy: IfNotPresent + replicas: 1 + gatewaySecretName: versus-operator-secrets + telegram: + enabled: true + secretName: versus-operator-secrets + agent: + enable: true + mode: detect + pollInterval: 15s + ai: + enable: true + provider: gemini # openai | gemini + model: gemini-2.5-flash-lite + apiKeySecretName: versus-operator-secrets + sources: + - name: demo-app + type: file + enable: true + file: + path: /app/data/app.log + fromBeginning: true diff --git a/operator/go.mod b/operator/go.mod new file mode 100644 index 0000000..3f3179b --- /dev/null +++ b/operator/go.mod @@ -0,0 +1,67 @@ +module github.com/VersusControl/versus-incident/operator + +go 1.25 + +require ( + k8s.io/api v0.31.0 + k8s.io/apimachinery v0.31.0 + k8s.io/client-go v0.31.0 + sigs.k8s.io/controller-runtime v0.19.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.31.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/operator/go.sum b/operator/go.sum new file mode 100644 index 0000000..5bbc855 --- /dev/null +++ b/operator/go.sum @@ -0,0 +1,194 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= +k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= +k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= +k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= +k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= +k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= +k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q= +sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/operator/internal/controller/versusincident_controller.go b/operator/internal/controller/versusincident_controller.go new file mode 100644 index 0000000..aae1581 --- /dev/null +++ b/operator/internal/controller/versusincident_controller.go @@ -0,0 +1,429 @@ +package controller + +import ( + "context" + "crypto/sha256" + "fmt" + "strings" + "text/template" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + 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" + + opsv1alpha1 "github.com/VersusControl/versus-incident/operator/api/v1alpha1" +) + +const ( + dataPath = "/app/data" + configPath = "/app/config" + appPort = 3000 + runAsUser = int64(65532) +) + +// VersusIncidentReconciler reconciles a VersusIncident object into a +// ConfigMap, Deployment and Service. +type VersusIncidentReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=ops.versuscontrol.io,resources=versusincidents,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=ops.versuscontrol.io,resources=versusincidents/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=ops.versuscontrol.io,resources=versusincidents/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=services;configmaps,verbs=get;list;watch;create;update;patch;delete + +// Reconcile drives the cluster toward the desired state described by a +// VersusIncident resource. +func (r *VersusIncidentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx) + + var vi opsv1alpha1.VersusIncident + if err := r.Get(ctx, req.NamespacedName, &vi); err != nil { + // Not found: owner references handle child GC. Nothing to do. + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + cfgYAML, sourcesYAML, err := buildConfigYAML(&vi) + if err != nil { + return ctrl.Result{}, fmt.Errorf("render config: %w", err) + } + cfgHash := fmt.Sprintf("%x", sha256.Sum256([]byte(cfgYAML+sourcesYAML))) + + // 1) ConfigMap + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name(&vi), Namespace: vi.Namespace}} + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, cm, func() error { + cm.Labels = labels(&vi) + cm.Data = map[string]string{"config.yaml": cfgYAML} + if vi.Spec.Agent.Enable { + cm.Data["agent_sources.yaml"] = sourcesYAML + } + return controllerutil.SetControllerReference(&vi, cm, r.Scheme) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("configmap: %w", err) + } + + // 2) Service + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: name(&vi), Namespace: vi.Namespace}} + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error { + svc.Labels = labels(&vi) + svc.Spec.Selector = selector(&vi) + svc.Spec.Type = corev1.ServiceTypeClusterIP + svc.Spec.Ports = []corev1.ServicePort{{ + Name: "http", + Port: appPort, + TargetPort: intstr.FromString("http"), + Protocol: corev1.ProtocolTCP, + }} + return controllerutil.SetControllerReference(&vi, svc, r.Scheme) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("service: %w", err) + } + + // 3) Deployment + dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: name(&vi), Namespace: vi.Namespace}} + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error { + mutateDeployment(&vi, dep, cfgHash) + return controllerutil.SetControllerReference(&vi, dep, r.Scheme) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("deployment: %w", err) + } + + // 4) Status + if err := r.Get(ctx, types.NamespacedName{Name: name(&vi), Namespace: vi.Namespace}, dep); err == nil { + base := vi.DeepCopy() + vi.Status.ReadyReplicas = dep.Status.ReadyReplicas + vi.Status.ObservedGeneration = vi.Generation + setReady(&vi, dep.Status.ReadyReplicas > 0) + // MergeFrom patch (no optimistic lock) avoids the benign "object has + // been modified" conflict between the cached read and this write. + if err := r.Status().Patch(ctx, &vi, client.MergeFrom(base)); err != nil { + l.Error(err, "status patch failed") + } + } + + return ctrl.Result{}, nil +} + +func mutateDeployment(vi *opsv1alpha1.VersusIncident, dep *appsv1.Deployment, cfgHash string) { + replicas := int32(1) + if vi.Spec.Replicas != nil { + replicas = *vi.Spec.Replicas + } + img := fmt.Sprintf("%s:%s", orDefault(vi.Spec.Image.Repository, "ghcr.io/versuscontrol/versus-incident"), orDefault(vi.Spec.Image.Tag, "v1.4.3")) + pull := vi.Spec.Image.PullPolicy + if pull == "" { + pull = corev1.PullIfNotPresent + } + + dep.Labels = labels(vi) + dep.Spec.Replicas = &replicas + dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: selector(vi)} + + nonRoot := true + fsGroup := runAsUser + uid := runAsUser + dep.Spec.Template = corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: selector(vi), + Annotations: map[string]string{"versuscontrol.io/config-hash": cfgHash}, + }, + Spec: corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: &fsGroup, + RunAsNonRoot: &nonRoot, + RunAsUser: &uid, + }, + Containers: []corev1.Container{{ + Name: "versus-incident", + Image: img, + ImagePullPolicy: pull, + Ports: []corev1.ContainerPort{{Name: "http", ContainerPort: appPort, Protocol: corev1.ProtocolTCP}}, + Env: buildEnv(vi), + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr(false), + RunAsNonRoot: &nonRoot, + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + }, + LivenessProbe: httpProbe(30), + ReadinessProbe: httpProbe(5), + VolumeMounts: volumeMounts(vi), + }}, + Volumes: volumes(vi), + }, + } +} + +func buildEnv(vi *opsv1alpha1.VersusIncident) []corev1.EnvVar { + env := []corev1.EnvVar{{Name: "STORAGE_TYPE", Value: "file"}} + if vi.Spec.GatewaySecretName != "" { + env = append(env, secretEnv("GATEWAY_SECRET", vi.Spec.GatewaySecretName, "gateway_secret")) + } + if vi.Spec.Telegram.Enabled { + env = append(env, + corev1.EnvVar{Name: "TELEGRAM_ENABLE", Value: "true"}, + secretEnv("TELEGRAM_BOT_TOKEN", vi.Spec.Telegram.SecretName, "telegram_bot_token"), + secretEnv("TELEGRAM_CHAT_ID", vi.Spec.Telegram.SecretName, "telegram_chat_id"), + ) + } + if vi.Spec.Agent.Enable { + env = append(env, + corev1.EnvVar{Name: "AGENT_ENABLE", Value: "true"}, + corev1.EnvVar{Name: "AGENT_MODE", Value: orDefault(vi.Spec.Agent.Mode, "training")}, + ) + if vi.Spec.Agent.AI.Enable { + env = append(env, + corev1.EnvVar{Name: "AGENT_AI_ENABLE", Value: "true"}, + corev1.EnvVar{Name: "AGENT_AI_MODEL", Value: orDefault(vi.Spec.Agent.AI.Model, "gpt-4o-mini")}, + secretEnv("AGENT_AI_API_KEY", vi.Spec.Agent.AI.APIKeySecretName, "agent_ai_api_key"), + ) + } + } + return env +} + +func volumeMounts(vi *opsv1alpha1.VersusIncident) []corev1.VolumeMount { + mounts := []corev1.VolumeMount{ + {Name: "config", MountPath: configPath + "/config.yaml", SubPath: "config.yaml"}, + {Name: "data", MountPath: dataPath}, + } + if vi.Spec.Agent.Enable { + mounts = append(mounts, corev1.VolumeMount{Name: "config", MountPath: configPath + "/agent_sources.yaml", SubPath: "agent_sources.yaml"}) + } + return mounts +} + +func volumes(vi *opsv1alpha1.VersusIncident) []corev1.Volume { + return []corev1.Volume{ + {Name: "config", VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: name(vi)}}}}, + {Name: "data", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + } +} + +func httpProbe(delay int32) *corev1.Probe { + return &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{ + Path: "/healthz", + Port: intstr.FromString("http"), + }}, + InitialDelaySeconds: delay, + PeriodSeconds: 10, + } +} + +func secretEnv(envName, secretName, key string) corev1.EnvVar { + return corev1.EnvVar{Name: envName, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: key, + }}} +} + +func setReady(vi *opsv1alpha1.VersusIncident, ready bool) { + cond := metav1.Condition{ + Type: "Ready", + ObservedGeneration: vi.Generation, + LastTransitionTime: metav1.Now(), + } + if ready { + cond.Status = metav1.ConditionTrue + cond.Reason = "DeploymentReady" + cond.Message = "managed Deployment has ready replicas" + } else { + cond.Status = metav1.ConditionFalse + cond.Reason = "DeploymentNotReady" + cond.Message = "waiting for the managed Deployment to become ready" + } + // Replace any existing Ready condition. + out := cond + conds := []metav1.Condition{out} + for _, c := range vi.Status.Conditions { + if c.Type != "Ready" { + conds = append(conds, c) + } + } + vi.Status.Conditions = conds +} + +func name(vi *opsv1alpha1.VersusIncident) string { return vi.Name } + +func labels(vi *opsv1alpha1.VersusIncident) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": "versus-incident", + "app.kubernetes.io/instance": vi.Name, + "app.kubernetes.io/managed-by": "versus-incident-operator", + } +} + +func selector(vi *opsv1alpha1.VersusIncident) map[string]string { + return map[string]string{ + "app.kubernetes.io/name": "versus-incident", + "app.kubernetes.io/instance": vi.Name, + } +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +func ptr[T any](v T) *T { return &v } + +// configTemplate renders the in-pod config.yaml. The agent detection blocks +// (regex / redaction / miner / catalog / service_patterns) are always +// included when the agent is enabled — without them the regex pre-filter is +// empty and the agent matches nothing. +var configTemplate = template.Must(template.New("config").Parse(`name: {{ .Name }} +host: 0.0.0.0 +port: 3000 + +gateway_secret: ${GATEWAY_SECRET} + +storage: + type: file + file: + max_incidents: 1000 + +alert: + debug_body: true + telegram: + enable: {{ .Telegram.Enabled }} + bot_token: ${TELEGRAM_BOT_TOKEN} + chat_id: ${TELEGRAM_CHAT_ID} + template_path: "/app/config/telegram_message.tmpl" + +redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + db: 0 +{{- if .Agent.Enable }} + +agent: + enable: true + mode: {{ .Agent.Mode }} + poll_interval: {{ .Agent.PollInterval }} + lookback: 10m + new_service_grace: "0" + sources_path: /app/config/agent_sources.yaml + batch_max: 5000 + signal_max_bytes: 65536 + redaction: + enable: true + redact_ips: false + extra_patterns: + - "(?i)password=\\S+" + - "Authorization:\\s*Bearer\\s+\\S+" + miner: + similarity_threshold: 0.4 + tree_depth: 4 + max_children: 100 + catalog: + persist_interval: 30s + auto_promote_after: 50 + spike_multiplier: 5.0 + spike_min_frequency: 5 + spike_min_baseline_count: 20 + regex: + default_pattern: "(?i).*error.*" + rules: + - name: oom-killer + pattern: "Out of memory: Killed process" + - name: panic + pattern: "(?i)panic:" + - name: 5xx-burst + pattern: "HTTP/[0-9.]+\\s+5\\d\\d" + service_patterns: + - '(?i)\bservice[._-]?name["\s:=]+"?([A-Za-z0-9._-]+)' + - '(?i)\b(?:service|svc|app|component)\s*=\s*"?([A-Za-z0-9._-]+)' + - '\[([A-Za-z0-9._-]+)\]' + ai: + enable: {{ .Agent.AI.Enable }} + api_key: ${AGENT_AI_API_KEY} +{{- if .Agent.AI.Provider }} + provider: "{{ .Agent.AI.Provider }}" +{{- end }} + model: "{{ .Agent.AI.Model }}" + temperature: 0.2 + max_tokens: 1024 + max_calls_per_hour: 60 + cache_ttl: "1h" +{{- end }} +`)) + +var sourcesTemplate = template.Must(template.New("sources").Parse(`sources: +{{- if .Agent.Sources }} +{{- range .Agent.Sources }} + - name: {{ .Name }} + type: {{ .Type }} + enable: {{ .Enable }} +{{- if and (eq .Type "file") .File }} + file: + path: {{ .File.Path }} + from_beginning: {{ .File.FromBeginning }} +{{- if .File.Format }} + format: {{ .File.Format }} +{{- end }} +{{- end }} +{{- if and (eq .Type "loki") .Loki }} + loki: + address: {{ .Loki.Address }} + query: {{ .Loki.Query | printf "%q" }} +{{- if .Loki.TenantID }} + tenant_id: {{ .Loki.TenantID }} +{{- end }} +{{- end }} +{{- if and (eq .Type "elasticsearch") .Elasticsearch }} + elasticsearch: + addresses: +{{- range .Elasticsearch.Addresses }} + - {{ . }} +{{- end }} + index: {{ .Elasticsearch.Index }} +{{- if .Elasticsearch.Query }} + query: {{ .Elasticsearch.Query | printf "%q" }} +{{- end }} +{{- end }} +{{- end }} +{{- else }} + [] +{{- end }} +`)) + +// tmplData carries the CR name alongside the embedded spec so templates can +// reference both {{ .Name }} and the promoted spec fields ({{ .Agent... }}). +type tmplData struct { + Name string + opsv1alpha1.VersusIncidentSpec +} + +func buildConfigYAML(vi *opsv1alpha1.VersusIncident) (string, string, error) { + data := tmplData{Name: vi.Name, VersusIncidentSpec: vi.Spec} + var cfg, src strings.Builder + if err := configTemplate.Execute(&cfg, data); err != nil { + return "", "", fmt.Errorf("config template: %w", err) + } + if err := sourcesTemplate.Execute(&src, data); err != nil { + return "", "", fmt.Errorf("sources template: %w", err) + } + return cfg.String(), src.String(), nil +} + +// SetupWithManager registers the controller and the resources it owns. +func (r *VersusIncidentReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&opsv1alpha1.VersusIncident{}). + Owns(&appsv1.Deployment{}). + Owns(&corev1.Service{}). + Owns(&corev1.ConfigMap{}). + Complete(r) +}