From 63389bdef26892807e945cefd88b952e1d05fe5f Mon Sep 17 00:00:00 2001 From: huskcass Date: Fri, 9 Oct 2026 18:04:15 +0800 Subject: [PATCH 1/2] feat(serving): LLMService v1alpha1 API and controller Multigroup layout (autoscaling, serving). LLMService reconciles one helm release through the helm v4 SDK: chart range resolution pinned in status.chart, layer merge, adopt-or-drift for releases it did not install, deletion-policy Orphan, one-shot force-conflicts, ControllerRevision history bounded to 10. Written with Grok Build (grok-4.7, high). --- Makefile | 5 +- PROJECT | 12 +- README.md | 2 +- .../v1alpha1/groupversion_info.go | 0 .../v1alpha1/llmscaler_types.go | 0 .../v1alpha1/zz_generated.deepcopy.go | 0 api/serving/v1alpha1/groupversion_info.go | 44 ++ api/serving/v1alpha1/llmservice_types.go | 321 ++++++++++ api/serving/v1alpha1/zz_generated.deepcopy.go | 268 +++++++++ cmd/main.go | 26 +- .../serving.modelsphere.dev_llmservices.yaml | 334 ++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 145 +++++ .../rbac/serving_llmservice_admin_role.yaml | 27 + .../rbac/serving_llmservice_editor_role.yaml | 33 + .../rbac/serving_llmservice_viewer_role.yaml | 29 + config/samples/kustomization.yaml | 1 + .../samples/serving_v1alpha1_llmservice.yaml | 47 ++ .../llmservices.serving.modelsphere.dev.yaml | 338 +++++++++++ dist/chart/templates/rbac/manager-role.yaml | 145 +++++ dist/install.yaml | 569 +++++++++++++++++- docs/llmservice.md | 47 ++ go.mod | 172 ++++-- go.sum | 460 ++++++++++---- internal/chart/repo.go | 288 +++++++++ internal/chart/repo_test.go | 191 ++++++ internal/chart/version.go | 128 ++++ internal/chart/version_test.go | 102 ++++ .../{ => autoscaling}/customprovider.go | 0 .../{ => autoscaling}/customprovider_test.go | 2 +- .../{ => autoscaling}/llmscaler_controller.go | 2 +- .../llmscaler_controller_test.go | 2 +- .../{ => autoscaling}/suite_test.go | 6 +- .../serving/llmservice_controller.go | 473 +++++++++++++++ .../serving/llmservice_controller_test.go | 443 ++++++++++++++ internal/controller/serving/predicate.go | 30 + internal/controller/serving/predicate_test.go | 45 ++ internal/controller/serving/suite_test.go | 118 ++++ internal/controller/serving/values.go | 119 ++++ internal/controller/serving/values_test.go | 80 +++ internal/helm/getter.go | 56 ++ internal/helm/helm.go | 192 ++++++ internal/helm/helm_test.go | 103 ++++ internal/helm/pull.go | 174 ++++++ 45 files changed, 5390 insertions(+), 193 deletions(-) rename api/{ => autoscaling}/v1alpha1/groupversion_info.go (100%) rename api/{ => autoscaling}/v1alpha1/llmscaler_types.go (100%) rename api/{ => autoscaling}/v1alpha1/zz_generated.deepcopy.go (100%) create mode 100644 api/serving/v1alpha1/groupversion_info.go create mode 100644 api/serving/v1alpha1/llmservice_types.go create mode 100644 api/serving/v1alpha1/zz_generated.deepcopy.go create mode 100644 config/crd/bases/serving.modelsphere.dev_llmservices.yaml create mode 100644 config/rbac/serving_llmservice_admin_role.yaml create mode 100644 config/rbac/serving_llmservice_editor_role.yaml create mode 100644 config/rbac/serving_llmservice_viewer_role.yaml create mode 100644 config/samples/serving_v1alpha1_llmservice.yaml create mode 100644 dist/chart/templates/crd/llmservices.serving.modelsphere.dev.yaml create mode 100644 docs/llmservice.md create mode 100644 internal/chart/repo.go create mode 100644 internal/chart/repo_test.go create mode 100644 internal/chart/version.go create mode 100644 internal/chart/version_test.go rename internal/controller/{ => autoscaling}/customprovider.go (100%) rename internal/controller/{ => autoscaling}/customprovider_test.go (99%) rename internal/controller/{ => autoscaling}/llmscaler_controller.go (99%) rename internal/controller/{ => autoscaling}/llmscaler_controller_test.go (99%) rename internal/controller/{ => autoscaling}/suite_test.go (93%) create mode 100644 internal/controller/serving/llmservice_controller.go create mode 100644 internal/controller/serving/llmservice_controller_test.go create mode 100644 internal/controller/serving/predicate.go create mode 100644 internal/controller/serving/predicate_test.go create mode 100644 internal/controller/serving/suite_test.go create mode 100644 internal/controller/serving/values.go create mode 100644 internal/controller/serving/values_test.go create mode 100644 internal/helm/getter.go create mode 100644 internal/helm/helm.go create mode 100644 internal/helm/helm_test.go create mode 100644 internal/helm/pull.go diff --git a/Makefile b/Makefile index 74fd61a..3ebb07e 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,7 @@ cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests @$(KIND) delete cluster --name $(KIND_CLUSTER) # Paths to lint/format. Override to scope to a subset, e.g. from the git hook: -# make lint-format LINT_PATHS="internal/controller/foo.go api/v1alpha1/bar.go" +# make lint-format LINT_PATHS="internal/controller/autoscaling/foo.go api/autoscaling/v1alpha1/bar.go" LINT_PATHS ?= ./... .PHONY: lint @@ -257,9 +257,10 @@ $(ENVTEST): $(LOCALBIN) golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + @# Upstream golangci-lint's go line is one release behind this module. @test -f .custom-gcl.yml && { \ echo "Building custom golangci-lint with plugins..." && \ - $(GOLANGCI_LINT) custom --destination $(LOCALBIN) --name golangci-lint-custom && \ + GOTOOLCHAIN=go$$(awk '/^go /{print $$2; exit}' go.mod) $(GOLANGCI_LINT) custom --destination $(LOCALBIN) --name golangci-lint-custom && \ mv -f $(LOCALBIN)/golangci-lint-custom $(GOLANGCI_LINT); \ } || true diff --git a/PROJECT b/PROJECT index 9746480..e5506fd 100644 --- a/PROJECT +++ b/PROJECT @@ -6,6 +6,7 @@ cliVersion: 4.15.0 domain: modelsphere.dev layout: - go.kubebuilder.io/v4 +multigroup: true plugins: helm.kubebuilder.io/v2-alpha: manifests: dist/install.yaml @@ -20,6 +21,15 @@ resources: domain: modelsphere.dev group: autoscaling kind: LLMScaler - path: github.com/modelsphere/llm-operator/api/v1alpha1 + path: github.com/modelsphere/llm-operator/api/autoscaling/v1alpha1 + version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: modelsphere.dev + group: serving + kind: LLMService + path: github.com/modelsphere/llm-operator/api/serving/v1alpha1 version: v1alpha1 version: "3" diff --git a/README.md b/README.md index 6858dff..bb49d27 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ docker build \ ## Description -The operator introduces a single CRD, **`LLMScaler`** (`autoscaling.modelsphere.dev`), that points at a scalable workload and drives its replica count from a metrics source. +The operator introduces two CRDs. **`LLMScaler`** (`autoscaling.modelsphere.dev`) points at a scalable workload and drives its replica count from a metrics source. **`LLMService`** (`serving.modelsphere.dev`) installs one Helm release per service; see [LLMService](docs/llmservice.md). - **Target** (`spec.targetRef`): any `Deployment`, `StatefulSet`, or `LeaderWorkerSet` (handled generically via the unstructured client). - **Metric provider** (`spec.metricProvider`): where the scaling signal comes from — `Prometheus` (default) or `Custom`. The two are alternatives, not layers: `Custom` replaces Prometheus outright and there is no fallback between them. See [Custom metric provider](#custom-metric-provider). diff --git a/api/v1alpha1/groupversion_info.go b/api/autoscaling/v1alpha1/groupversion_info.go similarity index 100% rename from api/v1alpha1/groupversion_info.go rename to api/autoscaling/v1alpha1/groupversion_info.go diff --git a/api/v1alpha1/llmscaler_types.go b/api/autoscaling/v1alpha1/llmscaler_types.go similarity index 100% rename from api/v1alpha1/llmscaler_types.go rename to api/autoscaling/v1alpha1/llmscaler_types.go diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/autoscaling/v1alpha1/zz_generated.deepcopy.go similarity index 100% rename from api/v1alpha1/zz_generated.deepcopy.go rename to api/autoscaling/v1alpha1/zz_generated.deepcopy.go diff --git a/api/serving/v1alpha1/groupversion_info.go b/api/serving/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..b0ad104 --- /dev/null +++ b/api/serving/v1alpha1/groupversion_info.go @@ -0,0 +1,44 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1alpha1 contains API Schema definitions for the serving v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=serving.modelsphere.dev +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + // SchemeGroupVersion is group version used to register these objects. + // This name is used by applyconfiguration generators (e.g. controller-gen). + SchemeGroupVersion = schema.GroupVersion{Group: "serving.modelsphere.dev", Version: "v1alpha1"} + + // GroupVersion is an alias for SchemeGroupVersion, for backward compatibility. + GroupVersion = SchemeGroupVersion + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error { + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil + }) + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/serving/v1alpha1/llmservice_types.go b/api/serving/v1alpha1/llmservice_types.go new file mode 100644 index 0000000..7c1035c --- /dev/null +++ b/api/serving/v1alpha1/llmservice_types.go @@ -0,0 +1,321 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + // ConditionApplied is true once the resolved chart and merged layers are + // applied to the helm release. + ConditionApplied = "Applied" + // ConditionAdopted is true once an existing helm release has been adopted. + ConditionAdopted = "Adopted" + + // ReasonDrift means adoption refused because the release does not match + // the resolved chart and merged layers. No upgrade is attempted. + ReasonDrift = "Drift" + // ReasonInstalled means the helm release was installed. + ReasonInstalled = "Installed" + // ReasonUpgraded means the helm release was upgraded. + ReasonUpgraded = "Upgraded" + // ReasonAdopted means an existing helm release was adopted without a helm call. + ReasonAdopted = "Adopted" + // ReasonFailed means reconciliation failed. + ReasonFailed = "Failed" + // ReasonApplyFailed means helm install or upgrade failed. + ReasonApplyFailed = "ApplyFailed" + // ReasonChartResolveFailed means spec.chart.version could not be resolved. + ReasonChartResolveFailed = "ChartResolveFailed" + // ReasonSuspended means spec.suspend stopped reconciliation. + ReasonSuspended = "Suspended" + + // Finalizer keeps the LLMService until the helm release is uninstalled, + // unless AnnotationDeletionPolicy is DeletionPolicyOrphan. + Finalizer = "serving.modelsphere.dev/finalizer" + + // AnnotationDeletionPolicy requests how deletion treats the helm release. + // The only value is DeletionPolicyOrphan. + AnnotationDeletionPolicy = "serving.modelsphere.dev/deletion-policy" + // DeletionPolicyOrphan drops the finalizer without uninstalling the release. + DeletionPolicyOrphan = "Orphan" + + // AnnotationForceConflicts, set to a generation, lets that generation's + // apply take fields other managers own. Any other generation ignores it. + AnnotationForceConflicts = "serving.modelsphere.dev/force-conflicts" + + // SwissAnnotationPrefix selects annotations copied into each history entry. + // The operator otherwise ignores them. + SwissAnnotationPrefix = "swiss.modelsphere.dev/" + + // DefaultHistoryLimit is how many status.history entries are kept, newest first. + DefaultHistoryLimit = 10 +) + +// Phase is the high-level reconciliation state of an LLMService. +type Phase string + +const ( + // PhasePending means the spec has not been applied yet. + PhasePending Phase = "Pending" + // PhaseApplying means a helm install or upgrade is in progress. + PhaseApplying Phase = "Applying" + // PhaseApplied means the helm release matches the resolved spec. + PhaseApplied Phase = "Applied" + // PhaseFailed means the last reconcile stopped on an error. + PhaseFailed Phase = "Failed" +) + +// LocalObjectReference names an object in the LLMService's namespace. +// credentialsRef uses it for the Secret that holds chart-pull credentials. +type LocalObjectReference struct { + // name of the Secret. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// ChartSpec selects the helm chart installed for this release. +type ChartSpec struct { + // name is the chart name. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // repo is the chart repository URL. It must start with https:// or oci://. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^(https://|oci://).+` + Repo string `json:"repo"` + + // version is an exact chart version or a semver range. The operator + // resolves it once per spec change and pins the result on status.chart. + // While the pinned version still satisfies the range, a newer chart in + // the repository does not move the release. + // +kubebuilder:validation:MinLength=1 + Version string `json:"version"` + + // credentialsRef optionally names a Secret in this namespace used to pull + // the chart and to list its versions. The Secret data must contain the + // keys username and password. When unset, the pull and the listing are anonymous. + // +optional + CredentialsRef *LocalObjectReference `json:"credentialsRef,omitempty"` +} + +// Layer is one values document. Layers merge in list order, last writer wins. +type Layer struct { + // name identifies this layer. Names must be unique within spec.layers. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // values is a schemaless object of helm values contributed by this layer. + // +kubebuilder:validation:Schemaless + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + // +optional + Values *apiextensionsv1.JSON `json:"values,omitempty"` +} + +// ModelSpec describes the model a service is serving. It is descriptive only: +// labels, printer columns, and catalog checks. It is not used to render the chart. +type ModelSpec struct { + // name of the model. + // +optional + Name string `json:"name,omitempty"` + + // version of the model. + // +optional + Version string `json:"version,omitempty"` + + // variant is the serving variant, such as a parallelism and hardware profile. + // +optional + Variant string `json:"variant,omitempty"` + + // hf is the Hugging Face repository id. + // +optional + HF string `json:"hf,omitempty"` + + // engine is the inference engine, such as sglang or vllm. + // +optional + Engine string `json:"engine,omitempty"` +} + +// LLMServiceSpec defines the desired state of LLMService. +type LLMServiceSpec struct { + // chart selects the helm chart for this release. + // +required + Chart ChartSpec `json:"chart"` + + // layers are helm values documents merged in order, last writer wins. + // Names are unique so each layer can be owned separately under + // server-side apply. Order is the meaning of the list, so one writer + // should own the whole list. An empty list is valid and leaves the + // chart defaults in place. + // +listType=map + // +listMapKey=name + // +optional + Layers []Layer `json:"layers,omitempty"` + + // model is descriptive metadata for the served model. It is not used to + // render the chart. + // +optional + Model *ModelSpec `json:"model,omitempty"` + + // suspend stops reconciliation. The status keeps the last outcome. + // +optional + Suspend bool `json:"suspend,omitempty"` +} + +// ChartStatus is the chart spec.chart.version resolved to. +type ChartStatus struct { + // name is the chart name that was resolved. + // +optional + Name string `json:"name,omitempty"` + + // version is the exact chart version resolved from spec.chart.version. + // +optional + Version string `json:"version,omitempty"` +} + +// HelmStatus is the helm release last observed for this LLMService. +type HelmStatus struct { + // revision is the helm release revision. + // +optional + Revision int `json:"revision,omitempty"` + + // status is the helm release status, such as deployed. + // +optional + Status string `json:"status,omitempty"` +} + +// HistoryEntry records one applied revision. status.history is newest first. +type HistoryEntry struct { + // revision is the helm release revision this entry records. + Revision int `json:"revision"` + + // hash is the appliedHash at this revision. + Hash string `json:"hash"` + + // appliedAt is when this revision was recorded. + AppliedAt metav1.Time `json:"appliedAt"` + + // controllerRevision is the name of the ControllerRevision that stores + // the spec snapshot for this revision. + ControllerRevision string `json:"controllerRevision"` + + // annotations are the swiss.modelsphere.dev/* annotations copied from the + // LLMService when this revision was recorded. + // +optional + Annotations map[string]string `json:"annotations,omitempty"` + + // forceConflicts records that this apply honored + // serving.modelsphere.dev/force-conflicts for its generation. + // +optional + ForceConflicts bool `json:"forceConflicts,omitempty"` + + // action is the swiss action copied for this revision (install, upgrade, + // rollback, or migrate), when one was set. + // +optional + Action string `json:"action,omitempty"` +} + +// LLMServiceStatus defines the observed state of LLMService. +type LLMServiceStatus struct { + // observedGeneration is the metadata.generation last reconciled to a + // terminal outcome. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // phase is the current reconciliation phase: Pending, Applying, Applied, + // or Failed. + // +kubebuilder:validation:Enum=Pending;Applying;Applied;Failed + // +optional + Phase Phase `json:"phase,omitempty"` + + // conditions represent the current state of the LLMService. + // Types used by the operator are Applied and Adopted. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // message is the helm error when phase is Failed, and empty otherwise. + // +optional + Message string `json:"message,omitempty"` + + // chart is the chart name and the exact version spec.chart.version + // resolved to. + // +optional + Chart ChartStatus `json:"chart,omitempty"` + + // appliedHash is a digest over status.chart and the merged layers. + // +optional + AppliedHash string `json:"appliedHash,omitempty"` + + // helm is the release revision and status last observed. + // +optional + Helm HelmStatus `json:"helm,omitempty"` + + // history records applies, newest first. The controller keeps the most + // recent DefaultHistoryLimit (10) entries. + // +optional + History []HistoryEntry `json:"history,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=llmservices,scope=Namespaced,shortName=llmsvc +// +kubebuilder:printcolumn:name="Chart",type="string",JSONPath=".spec.chart.name",description="Chart name" +// +kubebuilder:printcolumn:name="Version",type="string",JSONPath=".status.chart.version",description="Resolved chart version" +// +kubebuilder:printcolumn:name="Model",type="string",JSONPath=".spec.model.name",description="Model name" +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="Reconciliation phase" +// +kubebuilder:printcolumn:name="Revision",type="integer",JSONPath=".status.helm.revision",description="Helm release revision" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// LLMService is the Schema for the llmservices API. One LLMService maps to one +// helm release in the same namespace; metadata.name is the release name. +type LLMService struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of LLMService + // +required + Spec LLMServiceSpec `json:"spec"` + + // status defines the observed state of LLMService + // +optional + Status LLMServiceStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// LLMServiceList contains a list of LLMService +type LLMServiceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []LLMService `json:"items"` +} + +func init() { + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(SchemeGroupVersion, &LLMService{}, &LLMServiceList{}) + return nil + }) +} diff --git a/api/serving/v1alpha1/zz_generated.deepcopy.go b/api/serving/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..0eb6875 --- /dev/null +++ b/api/serving/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,268 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChartSpec) DeepCopyInto(out *ChartSpec) { + *out = *in + if in.CredentialsRef != nil { + in, out := &in.CredentialsRef, &out.CredentialsRef + *out = new(LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartSpec. +func (in *ChartSpec) DeepCopy() *ChartSpec { + if in == nil { + return nil + } + out := new(ChartSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChartStatus) DeepCopyInto(out *ChartStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartStatus. +func (in *ChartStatus) DeepCopy() *ChartStatus { + if in == nil { + return nil + } + out := new(ChartStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmStatus) DeepCopyInto(out *HelmStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmStatus. +func (in *HelmStatus) DeepCopy() *HelmStatus { + if in == nil { + return nil + } + out := new(HelmStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HistoryEntry) DeepCopyInto(out *HistoryEntry) { + *out = *in + in.AppliedAt.DeepCopyInto(&out.AppliedAt) + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HistoryEntry. +func (in *HistoryEntry) DeepCopy() *HistoryEntry { + if in == nil { + return nil + } + out := new(HistoryEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLMService) DeepCopyInto(out *LLMService) { + *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 LLMService. +func (in *LLMService) DeepCopy() *LLMService { + if in == nil { + return nil + } + out := new(LLMService) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LLMService) 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 *LLMServiceList) DeepCopyInto(out *LLMServiceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LLMService, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLMServiceList. +func (in *LLMServiceList) DeepCopy() *LLMServiceList { + if in == nil { + return nil + } + out := new(LLMServiceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LLMServiceList) 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 *LLMServiceSpec) DeepCopyInto(out *LLMServiceSpec) { + *out = *in + in.Chart.DeepCopyInto(&out.Chart) + if in.Layers != nil { + in, out := &in.Layers, &out.Layers + *out = make([]Layer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Model != nil { + in, out := &in.Model, &out.Model + *out = new(ModelSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLMServiceSpec. +func (in *LLMServiceSpec) DeepCopy() *LLMServiceSpec { + if in == nil { + return nil + } + out := new(LLMServiceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLMServiceStatus) DeepCopyInto(out *LLMServiceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.Chart = in.Chart + out.Helm = in.Helm + if in.History != nil { + in, out := &in.History, &out.History + *out = make([]HistoryEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLMServiceStatus. +func (in *LLMServiceStatus) DeepCopy() *LLMServiceStatus { + if in == nil { + return nil + } + out := new(LLMServiceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Layer) DeepCopyInto(out *Layer) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Layer. +func (in *Layer) DeepCopy() *Layer { + if in == nil { + return nil + } + out := new(Layer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalObjectReference) DeepCopyInto(out *LocalObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalObjectReference. +func (in *LocalObjectReference) DeepCopy() *LocalObjectReference { + if in == nil { + return nil + } + out := new(LocalObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelSpec) DeepCopyInto(out *ModelSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelSpec. +func (in *ModelSpec) DeepCopy() *ModelSpec { + if in == nil { + return nil + } + out := new(ModelSpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 86b2ca6..1aa7d98 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,6 +20,7 @@ import ( "crypto/tls" "flag" "os" + "path/filepath" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -35,8 +36,11 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" - autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/v1alpha1" - "github.com/modelsphere/llm-operator/internal/controller" + autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/autoscaling/v1alpha1" + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" + controller "github.com/modelsphere/llm-operator/internal/controller/autoscaling" + servingcontroller "github.com/modelsphere/llm-operator/internal/controller/serving" + "github.com/modelsphere/llm-operator/internal/helm" // +kubebuilder:scaffold:imports ) @@ -49,6 +53,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(autoscalingv1alpha1.AddToScheme(scheme)) + utilruntime.Must(servingv1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -79,6 +84,13 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + var llmserviceHistoryLimit int + var llmserviceChartCacheDir string + flag.IntVar(&llmserviceHistoryLimit, "llmservice-history-limit", servingv1alpha1.DefaultHistoryLimit, + "Maximum status.history entries kept for each LLMService.") + flag.StringVar(&llmserviceChartCacheDir, "llmservice-chart-cache-dir", + filepath.Join(os.TempDir(), "llm-operator-charts"), + "Directory for Helm charts cached by the LLMService controller.") opts := zap.Options{ Development: true, } @@ -185,6 +197,16 @@ func main() { setupLog.Error(err, "Failed to create controller", "controller", "llmscaler") os.Exit(1) } + if err := (&servingcontroller.LLMServiceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Helm: helm.New(mgr.GetConfig(), llmserviceChartCacheDir), + Recorder: mgr.GetEventRecorder("llmservice"), + HistoryLimit: llmserviceHistoryLimit, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "serving-llmservice") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/serving.modelsphere.dev_llmservices.yaml b/config/crd/bases/serving.modelsphere.dev_llmservices.yaml new file mode 100644 index 0000000..2cbf3b3 --- /dev/null +++ b/config/crd/bases/serving.modelsphere.dev_llmservices.yaml @@ -0,0 +1,334 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: llmservices.serving.modelsphere.dev +spec: + group: serving.modelsphere.dev + names: + kind: LLMService + listKind: LLMServiceList + plural: llmservices + shortNames: + - llmsvc + singular: llmservice + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Chart name + jsonPath: .spec.chart.name + name: Chart + type: string + - description: Resolved chart version + jsonPath: .status.chart.version + name: Version + type: string + - description: Model name + jsonPath: .spec.model.name + name: Model + type: string + - description: Reconciliation phase + jsonPath: .status.phase + name: Phase + type: string + - description: Helm release revision + jsonPath: .status.helm.revision + name: Revision + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + LLMService is the Schema for the llmservices API. One LLMService maps to one + helm release in the same namespace; metadata.name is the release name. + 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: spec defines the desired state of LLMService + properties: + chart: + description: chart selects the helm chart for this release. + properties: + credentialsRef: + description: |- + credentialsRef optionally names a Secret in this namespace used to pull + the chart and to list its versions. The Secret data must contain the + keys username and password. When unset, the pull and the listing are anonymous. + properties: + name: + description: name of the Secret. + minLength: 1 + type: string + required: + - name + type: object + name: + description: name is the chart name. + minLength: 1 + type: string + repo: + description: repo is the chart repository URL. It must start with + https:// or oci://. + minLength: 1 + pattern: ^(https://|oci://).+ + type: string + version: + description: |- + version is an exact chart version or a semver range. The operator + resolves it once per spec change and pins the result on status.chart. + While the pinned version still satisfies the range, a newer chart in + the repository does not move the release. + minLength: 1 + type: string + required: + - name + - repo + - version + type: object + layers: + description: |- + layers are helm values documents merged in order, last writer wins. + Names are unique so each layer can be owned separately under + server-side apply. Order is the meaning of the list, so one writer + should own the whole list. An empty list is valid and leaves the + chart defaults in place. + items: + description: Layer is one values document. Layers merge in list + order, last writer wins. + properties: + name: + description: name identifies this layer. Names must be unique + within spec.layers. + minLength: 1 + type: string + values: + description: values is a schemaless object of helm values contributed + by this layer. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + model: + description: |- + model is descriptive metadata for the served model. It is not used to + render the chart. + properties: + engine: + description: engine is the inference engine, such as sglang or + vllm. + type: string + hf: + description: hf is the Hugging Face repository id. + type: string + name: + description: name of the model. + type: string + variant: + description: variant is the serving variant, such as a parallelism + and hardware profile. + type: string + version: + description: version of the model. + type: string + type: object + suspend: + description: suspend stops reconciliation. The status keeps the last + outcome. + type: boolean + required: + - chart + type: object + status: + description: status defines the observed state of LLMService + properties: + appliedHash: + description: appliedHash is a digest over status.chart and the merged + layers. + type: string + chart: + description: |- + chart is the chart name and the exact version spec.chart.version + resolved to. + properties: + name: + description: name is the chart name that was resolved. + type: string + version: + description: version is the exact chart version resolved from + spec.chart.version. + type: string + type: object + conditions: + description: |- + conditions represent the current state of the LLMService. + Types used by the operator are Applied and Adopted. + 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 + helm: + description: helm is the release revision and status last observed. + properties: + revision: + description: revision is the helm release revision. + type: integer + status: + description: status is the helm release status, such as deployed. + type: string + type: object + history: + description: |- + history records applies, newest first. The controller keeps the most + recent DefaultHistoryLimit (10) entries. + items: + description: HistoryEntry records one applied revision. status.history + is newest first. + properties: + action: + description: |- + action is the swiss action copied for this revision (install, upgrade, + rollback, or migrate), when one was set. + type: string + annotations: + additionalProperties: + type: string + description: |- + annotations are the swiss.modelsphere.dev/* annotations copied from the + LLMService when this revision was recorded. + type: object + appliedAt: + description: appliedAt is when this revision was recorded. + format: date-time + type: string + controllerRevision: + description: |- + controllerRevision is the name of the ControllerRevision that stores + the spec snapshot for this revision. + type: string + forceConflicts: + description: |- + forceConflicts records that this apply honored + serving.modelsphere.dev/force-conflicts for its generation. + type: boolean + hash: + description: hash is the appliedHash at this revision. + type: string + revision: + description: revision is the helm release revision this entry + records. + type: integer + required: + - appliedAt + - controllerRevision + - hash + - revision + type: object + type: array + message: + description: message is the helm error when phase is Failed, and empty + otherwise. + type: string + observedGeneration: + description: |- + observedGeneration is the metadata.generation last reconciled to a + terminal outcome. + format: int64 + type: integer + phase: + description: |- + phase is the current reconciliation phase: Pending, Applying, Applied, + or Failed. + enum: + - Pending + - Applying + - Applied + - Failed + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1c9945d..f516665 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/autoscaling.modelsphere.dev_llmscalers.yaml +- bases/serving.modelsphere.dev_llmservices.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 62d08ac..43d648a 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,6 +22,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the llmscaleoperator itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- serving_llmservice_admin_role.yaml +- serving_llmservice_editor_role.yaml +- serving_llmservice_viewer_role.yaml - llmscaler_admin_role.yaml - llmscaler_editor_role.yaml - llmscaler_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5755d39..e2cf5b0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,29 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - create + - get + - patch - apiGroups: - "" resources: @@ -16,7 +39,19 @@ rules: - apiGroups: - apps resources: + - controllerrevisions - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: - statefulsets verbs: - get @@ -25,6 +60,7 @@ rules: - update - watch - apiGroups: + - autoscaling.4pd.io - autoscaling.modelsphere.dev resources: - llmscalers @@ -50,13 +86,122 @@ rules: - get - patch - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch +- apiGroups: + - inference.modelsphere.dev + - inference.x-k8s.io + resources: + - llmslorequirements + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - leaderworkerset.x-k8s.io resources: - leaderworkersets verbs: + - create + - delete - get - list - patch - update - watch +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - routing.gpucluster.io + - routing.modelsphere.dev + resources: + - modelroutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/finalizers + verbs: + - update +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get + - patch + - update diff --git a/config/rbac/serving_llmservice_admin_role.yaml b/config/rbac/serving_llmservice_admin_role.yaml new file mode 100644 index 0000000..634d683 --- /dev/null +++ b/config/rbac/serving_llmservice_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project llmscaleoperator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over serving.modelsphere.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: llmscaleoperator + app.kubernetes.io/managed-by: kustomize + name: serving-llmservice-admin-role +rules: +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - '*' +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get diff --git a/config/rbac/serving_llmservice_editor_role.yaml b/config/rbac/serving_llmservice_editor_role.yaml new file mode 100644 index 0000000..13bbfe3 --- /dev/null +++ b/config/rbac/serving_llmservice_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project llmscaleoperator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the serving.modelsphere.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: llmscaleoperator + app.kubernetes.io/managed-by: kustomize + name: serving-llmservice-editor-role +rules: +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get diff --git a/config/rbac/serving_llmservice_viewer_role.yaml b/config/rbac/serving_llmservice_viewer_role.yaml new file mode 100644 index 0000000..5e56b4a --- /dev/null +++ b/config/rbac/serving_llmservice_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project llmscaleoperator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to serving.modelsphere.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: llmscaleoperator + app.kubernetes.io/managed-by: kustomize + name: serving-llmservice-viewer-role +rules: +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - get + - list + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 22448ef..0128b94 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -2,4 +2,5 @@ resources: - autoscaling_v1alpha1_llmscaler.yaml - autoscaling_v1alpha1_llmscaler_customprovider.yaml +- serving_v1alpha1_llmservice.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/serving_v1alpha1_llmservice.yaml b/config/samples/serving_v1alpha1_llmservice.yaml new file mode 100644 index 0000000..8fbde40 --- /dev/null +++ b/config/samples/serving_v1alpha1_llmservice.yaml @@ -0,0 +1,47 @@ +apiVersion: serving.modelsphere.dev/v1alpha1 +kind: LLMService +metadata: + labels: + app.kubernetes.io/name: llmscaleoperator + app.kubernetes.io/managed-by: kustomize + name: qwen + namespace: models + annotations: + swiss.modelsphere.dev/plan-hash: sha256:9f2c4a1b8e0d + swiss.modelsphere.dev/catalog: /models/index.json + swiss.modelsphere.dev/catalog-name: public + swiss.modelsphere.dev/catalog-ref: sha256:c0ffee + swiss.modelsphere.dev/entry-digest: sha256:a1b2c3 + swiss.modelsphere.dev/profile: prod + swiss.modelsphere.dev/action: upgrade + swiss.modelsphere.dev/note: "bump to 1.1.0" +spec: + chart: + name: sglang + repo: oci://ghcr.io/modelsphere/charts + version: ">=0.8.0" + # Secret data keys: username, password. Omit credentialsRef for an anonymous pull. + credentialsRef: + name: chart-pull + layers: + - name: catalog + values: + model: qwen3.6-35b-a3b + engine: sglang + - name: site + values: + replicaCount: 2 + resources: + limits: + nvidia.com/gpu: "1" + - name: derived + values: + service: + port: 30000 + model: + name: qwen3.6-35b-a3b + version: "1.1.0" + variant: sglang-tp2-h100 + hf: Qwen/Qwen3.6-35B-A3B + engine: sglang + suspend: false diff --git a/dist/chart/templates/crd/llmservices.serving.modelsphere.dev.yaml b/dist/chart/templates/crd/llmservices.serving.modelsphere.dev.yaml new file mode 100644 index 0000000..c5f784d --- /dev/null +++ b/dist/chart/templates/crd/llmservices.serving.modelsphere.dev.yaml @@ -0,0 +1,338 @@ +{{- if .Values.crd.enabled }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.21.0 + name: llmservices.serving.modelsphere.dev +spec: + group: serving.modelsphere.dev + names: + kind: LLMService + listKind: LLMServiceList + plural: llmservices + shortNames: + - llmsvc + singular: llmservice + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Chart name + jsonPath: .spec.chart.name + name: Chart + type: string + - description: Resolved chart version + jsonPath: .status.chart.version + name: Version + type: string + - description: Model name + jsonPath: .spec.model.name + name: Model + type: string + - description: Reconciliation phase + jsonPath: .status.phase + name: Phase + type: string + - description: Helm release revision + jsonPath: .status.helm.revision + name: Revision + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + LLMService is the Schema for the llmservices API. One LLMService maps to one + helm release in the same namespace; metadata.name is the release name. + 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: spec defines the desired state of LLMService + properties: + chart: + description: chart selects the helm chart for this release. + properties: + credentialsRef: + description: |- + credentialsRef optionally names a Secret in this namespace used to pull + the chart and to list its versions. The Secret data must contain the + keys username and password. When unset, the pull and the listing are anonymous. + properties: + name: + description: name of the Secret. + minLength: 1 + type: string + required: + - name + type: object + name: + description: name is the chart name. + minLength: 1 + type: string + repo: + description: repo is the chart repository URL. It must start with + https:// or oci://. + minLength: 1 + pattern: ^(https://|oci://).+ + type: string + version: + description: |- + version is an exact chart version or a semver range. The operator + resolves it once per spec change and pins the result on status.chart. + While the pinned version still satisfies the range, a newer chart in + the repository does not move the release. + minLength: 1 + type: string + required: + - name + - repo + - version + type: object + layers: + description: |- + layers are helm values documents merged in order, last writer wins. + Names are unique so each layer can be owned separately under + server-side apply. Order is the meaning of the list, so one writer + should own the whole list. An empty list is valid and leaves the + chart defaults in place. + items: + description: Layer is one values document. Layers merge in list + order, last writer wins. + properties: + name: + description: name identifies this layer. Names must be unique + within spec.layers. + minLength: 1 + type: string + values: + description: values is a schemaless object of helm values contributed + by this layer. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + model: + description: |- + model is descriptive metadata for the served model. It is not used to + render the chart. + properties: + engine: + description: engine is the inference engine, such as sglang or + vllm. + type: string + hf: + description: hf is the Hugging Face repository id. + type: string + name: + description: name of the model. + type: string + variant: + description: variant is the serving variant, such as a parallelism + and hardware profile. + type: string + version: + description: version of the model. + type: string + type: object + suspend: + description: suspend stops reconciliation. The status keeps the last + outcome. + type: boolean + required: + - chart + type: object + status: + description: status defines the observed state of LLMService + properties: + appliedHash: + description: appliedHash is a digest over status.chart and the merged + layers. + type: string + chart: + description: |- + chart is the chart name and the exact version spec.chart.version + resolved to. + properties: + name: + description: name is the chart name that was resolved. + type: string + version: + description: version is the exact chart version resolved from + spec.chart.version. + type: string + type: object + conditions: + description: |- + conditions represent the current state of the LLMService. + Types used by the operator are Applied and Adopted. + 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 + helm: + description: helm is the release revision and status last observed. + properties: + revision: + description: revision is the helm release revision. + type: integer + status: + description: status is the helm release status, such as deployed. + type: string + type: object + history: + description: |- + history records applies, newest first. The controller keeps the most + recent DefaultHistoryLimit (10) entries. + items: + description: HistoryEntry records one applied revision. status.history + is newest first. + properties: + action: + description: |- + action is the swiss action copied for this revision (install, upgrade, + rollback, or migrate), when one was set. + type: string + annotations: + additionalProperties: + type: string + description: |- + annotations are the swiss.modelsphere.dev/* annotations copied from the + LLMService when this revision was recorded. + type: object + appliedAt: + description: appliedAt is when this revision was recorded. + format: date-time + type: string + controllerRevision: + description: |- + controllerRevision is the name of the ControllerRevision that stores + the spec snapshot for this revision. + type: string + forceConflicts: + description: |- + forceConflicts records that this apply honored + serving.modelsphere.dev/force-conflicts for its generation. + type: boolean + hash: + description: hash is the appliedHash at this revision. + type: string + revision: + description: revision is the helm release revision this entry + records. + type: integer + required: + - appliedAt + - controllerRevision + - hash + - revision + type: object + type: array + message: + description: message is the helm error when phase is Failed, and empty + otherwise. + type: string + observedGeneration: + description: |- + observedGeneration is the metadata.generation last reconciled to a + terminal outcome. + format: int64 + type: integer + phase: + description: |- + phase is the current reconciliation phase: Pending, Applying, Applied, + or Failed. + enum: + - Pending + - Applying + - Applied + - Failed + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/dist/chart/templates/rbac/manager-role.yaml b/dist/chart/templates/rbac/manager-role.yaml index febc552..c671075 100644 --- a/dist/chart/templates/rbac/manager-role.yaml +++ b/dist/chart/templates/rbac/manager-role.yaml @@ -10,6 +10,29 @@ metadata: {{- end }} name: {{ include "llmscaleoperator.resourceName" (dict "suffix" "manager-role" "context" $) }} rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - create + - get + - patch - apiGroups: - "" resources: @@ -22,7 +45,19 @@ rules: - apiGroups: - apps resources: + - controllerrevisions - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: - statefulsets verbs: - get @@ -31,6 +66,7 @@ rules: - update - watch - apiGroups: + - autoscaling.4pd.io - autoscaling.modelsphere.dev resources: - llmscalers @@ -56,13 +92,122 @@ rules: - get - patch - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch +- apiGroups: + - inference.modelsphere.dev + - inference.x-k8s.io + resources: + - llmslorequirements + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - leaderworkerset.x-k8s.io resources: - leaderworkersets verbs: + - create + - delete - get - list - patch - update - watch +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - routing.gpucluster.io + - routing.modelsphere.dev + resources: + - modelroutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/finalizers + verbs: + - update +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get + - patch + - update diff --git a/dist/install.yaml b/dist/install.yaml index ea04c10..e2a45b7 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -93,10 +93,12 @@ spec: - serviceId type: object maxReplicas: - description: maxReplicas is the upper limit for the number of replicas - to which the autoscaler can scale up + description: |- + maxReplicas is the upper limit for the number of replicas to which the + autoscaler can scale up. 0 pins the fleet at zero replicas, which + combined with minReplicas: 0 disables scaling entirely. format: int32 - minimum: 1 + minimum: 0 type: integer metricProvider: default: Prometheus @@ -153,10 +155,15 @@ spec: type: object type: array minReplicas: - description: minReplicas is the lower limit for the number of replicas - to which the autoscaler can scale down + description: |- + minReplicas is the lower limit for the number of replicas to which the + autoscaler can scale down. 0 enables scale-to-zero: an idle target is + scaled all the way down, and wakes back up when a metric evaluates to a + positive recommendation (under the Prometheus provider the ratio is then + computed against a single-replica baseline, since a fleet of zero has no + per-replica measurement to scale off). format: int32 - minimum: 1 + minimum: 0 type: integer preemption: description: preemption defines whether priority-based preemption @@ -365,6 +372,340 @@ spec: subresources: status: {} --- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: llmservices.serving.modelsphere.dev +spec: + group: serving.modelsphere.dev + names: + kind: LLMService + listKind: LLMServiceList + plural: llmservices + shortNames: + - llmsvc + singular: llmservice + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Chart name + jsonPath: .spec.chart.name + name: Chart + type: string + - description: Resolved chart version + jsonPath: .status.chart.version + name: Version + type: string + - description: Model name + jsonPath: .spec.model.name + name: Model + type: string + - description: Reconciliation phase + jsonPath: .status.phase + name: Phase + type: string + - description: Helm release revision + jsonPath: .status.helm.revision + name: Revision + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + LLMService is the Schema for the llmservices API. One LLMService maps to one + helm release in the same namespace; metadata.name is the release name. + 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: spec defines the desired state of LLMService + properties: + chart: + description: chart selects the helm chart for this release. + properties: + credentialsRef: + description: |- + credentialsRef optionally names a Secret in this namespace used to pull + the chart and to list its versions. The Secret data must contain the + keys username and password. When unset, the pull and the listing are anonymous. + properties: + name: + description: name of the Secret. + minLength: 1 + type: string + required: + - name + type: object + name: + description: name is the chart name. + minLength: 1 + type: string + repo: + description: repo is the chart repository URL. It must start with + https:// or oci://. + minLength: 1 + pattern: ^(https://|oci://).+ + type: string + version: + description: |- + version is an exact chart version or a semver range. The operator + resolves it once per spec change and pins the result on status.chart. + While the pinned version still satisfies the range, a newer chart in + the repository does not move the release. + minLength: 1 + type: string + required: + - name + - repo + - version + type: object + layers: + description: |- + layers are helm values documents merged in order, last writer wins. + Names are unique so each layer can be owned separately under + server-side apply. Order is the meaning of the list, so one writer + should own the whole list. An empty list is valid and leaves the + chart defaults in place. + items: + description: Layer is one values document. Layers merge in list + order, last writer wins. + properties: + name: + description: name identifies this layer. Names must be unique + within spec.layers. + minLength: 1 + type: string + values: + description: values is a schemaless object of helm values contributed + by this layer. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + model: + description: |- + model is descriptive metadata for the served model. It is not used to + render the chart. + properties: + engine: + description: engine is the inference engine, such as sglang or + vllm. + type: string + hf: + description: hf is the Hugging Face repository id. + type: string + name: + description: name of the model. + type: string + variant: + description: variant is the serving variant, such as a parallelism + and hardware profile. + type: string + version: + description: version of the model. + type: string + type: object + suspend: + description: suspend stops reconciliation. The status keeps the last + outcome. + type: boolean + required: + - chart + type: object + status: + description: status defines the observed state of LLMService + properties: + appliedHash: + description: appliedHash is a digest over status.chart and the merged + layers. + type: string + chart: + description: |- + chart is the chart name and the exact version spec.chart.version + resolved to. + properties: + name: + description: name is the chart name that was resolved. + type: string + version: + description: version is the exact chart version resolved from + spec.chart.version. + type: string + type: object + conditions: + description: |- + conditions represent the current state of the LLMService. + Types used by the operator are Applied and Adopted. + 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 + helm: + description: helm is the release revision and status last observed. + properties: + revision: + description: revision is the helm release revision. + type: integer + status: + description: status is the helm release status, such as deployed. + type: string + type: object + history: + description: |- + history records applies, newest first. The controller keeps the most + recent DefaultHistoryLimit (10) entries. + items: + description: HistoryEntry records one applied revision. status.history + is newest first. + properties: + action: + description: |- + action is the swiss action copied for this revision (install, upgrade, + rollback, or migrate), when one was set. + type: string + annotations: + additionalProperties: + type: string + description: |- + annotations are the swiss.modelsphere.dev/* annotations copied from the + LLMService when this revision was recorded. + type: object + appliedAt: + description: appliedAt is when this revision was recorded. + format: date-time + type: string + controllerRevision: + description: |- + controllerRevision is the name of the ControllerRevision that stores + the spec snapshot for this revision. + type: string + forceConflicts: + description: |- + forceConflicts records that this apply honored + serving.modelsphere.dev/force-conflicts for its generation. + type: boolean + hash: + description: hash is the appliedHash at this revision. + type: string + revision: + description: revision is the helm release revision this entry + records. + type: integer + required: + - appliedAt + - controllerRevision + - hash + - revision + type: object + type: array + message: + description: message is the helm error when phase is Failed, and empty + otherwise. + type: string + observedGeneration: + description: |- + observedGeneration is the metadata.generation last reconciled to a + terminal outcome. + format: int64 + type: integer + phase: + description: |- + phase is the current reconciliation phase: Pending, Applying, Applied, + or Failed. + enum: + - Pending + - Applying + - Applied + - Failed + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- apiVersion: v1 kind: ServiceAccount metadata: @@ -491,6 +832,29 @@ kind: ClusterRole metadata: name: llmscaleoperator-manager-role rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - create + - get + - patch - apiGroups: - "" resources: @@ -503,7 +867,19 @@ rules: - apiGroups: - apps resources: + - controllerrevisions - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: - statefulsets verbs: - get @@ -512,6 +888,7 @@ rules: - update - watch - apiGroups: + - autoscaling.4pd.io - autoscaling.modelsphere.dev resources: - llmscalers @@ -537,16 +914,125 @@ rules: - get - patch - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - events.k8s.io + resources: + - events + verbs: + - create + - patch +- apiGroups: + - inference.modelsphere.dev + - inference.x-k8s.io + resources: + - llmslorequirements + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - leaderworkerset.x-k8s.io resources: - leaderworkersets verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - monitoring.coreos.com + resources: + - servicemonitors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - routing.gpucluster.io + - routing.modelsphere.dev + resources: + - modelroutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - create + - delete - get - list - patch - update - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/finalizers + verbs: + - update +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get + - patch + - update --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -577,6 +1063,77 @@ rules: - get --- apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: llmscaleoperator + name: llmscaleoperator-serving-llmservice-admin-role +rules: +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - '*' +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: llmscaleoperator + name: llmscaleoperator-serving-llmservice-editor-role +rules: +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: llmscaleoperator + name: llmscaleoperator-serving-llmservice-viewer-role +rules: +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices + verbs: + - get + - list + - watch +- apiGroups: + - serving.modelsphere.dev + resources: + - llmservices/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: labels: diff --git a/docs/llmservice.md b/docs/llmservice.md new file mode 100644 index 0000000..f58ac4c --- /dev/null +++ b/docs/llmservice.md @@ -0,0 +1,47 @@ +# LLMService + +`serving.modelsphere.dev/v1alpha1` kind `LLMService` (plural `llmservices`, short name `llmsvc`) maps one namespaced object to one Helm release. `metadata.name` is the release name. The release is stored with Helm's Secret driver, so `helm list` in that namespace still shows it. + +## Spec + +| Field | Meaning | +| --- | --- | +| `spec.chart.name` | Chart name. | +| `spec.chart.repo` | `https://…` or `oci://…` repository. | +| `spec.chart.version` | Exact version or a semver range. Resolved once and pinned on `status.chart`. A newer chart in the repository does not move the release while the pinned version is still in range. | +| `spec.chart.credentialsRef` | Optional Secret in this namespace. `data.username` and `data.password` are basic-auth credentials for listing and pulling. Unset means anonymous. | +| `spec.layers` | Helm values documents, merged in order. Maps merge recursively, other values replace, and a JSON null deletes a key. Names are unique. An empty list leaves chart defaults. | +| `spec.model` | Descriptive only. Not rendered into the chart. | +| `spec.suspend` | Stop reconciling. Status, including a finalizer, stays as it was. A suspended object is not uninstalled while it is deleting. | + +`status.appliedHash` is `sha256:` plus the hex SHA-256 of the JSON object `{chart:{name,version}, values}` with sorted keys. `status.history` is newest first. + +## Reconcile + +1. `spec.suspend` is true: return without writing status or calling Helm. +2. The object is deleting. `serving.modelsphere.dev/deletion-policy: Orphan` drops the finalizer and leaves the release. Otherwise the release is uninstalled (a missing release is fine) and then the finalizer is dropped. +3. Add finalizer `serving.modelsphere.dev/finalizer`. +4. Resolve `spec.chart.version` with the same rule as swiss `chart.Resolve`: an exact version, else the pinned `status.chart.version` while the range allows it, else the newest version the repository lists. A resolve error sets `phase: Failed`, condition `Applied=False` reason `ChartResolveFailed`, and retries with backoff. +5. Merge layers and hash them with the resolved chart. The same hash as `status.appliedHash` sets `phase: Applied` and `observedGeneration` when those are stale, then stops. No new history entry. +6. If a release exists and this object has never applied (`appliedHash` empty and `history` empty), adopt it. The release chart name, chart version, and user-supplied values must equal the resolved chart and merged layers after a JSON round trip. Equal: record `appliedHash`, `Adopted=True` reason `Adopted`, `Applied=True` reason `Adopted`, a history entry, and a ControllerRevision. No Helm install or upgrade. Not equal: `phase: Failed`, `Adopted=False` reason `Drift`, message names chart and/or values. No upgrade, and no requeue; the next spec change tries again. +7. Otherwise set `phase: Applying`, then install or upgrade through the Helm SDK with server-side apply and no resource wait (`HookOnlyStrategy`, the same outcome as `wait: false`). `serving.modelsphere.dev/force-conflicts` equal to the current generation sets Helm `ForceConflicts` for that apply and records `forceConflicts` on the history entry. Any other value is ignored. + +On Helm error: `phase: Failed`, `Applied=False` reason `ApplyFailed`, `message` is the error, `observedGeneration` is the generation, and the error is returned for backoff. + +On success the controller writes a ControllerRevision named `-<8 hex chars of the hash>` in the same namespace. It is owned by the LLMService, labelled `serving.modelsphere.dev/llmservice=`, and its data is JSON `{spec, annotations}` where annotations are only `swiss.modelsphere.dev/*`. `status.history` is prepended and capped (default 10, flag `--llmservice-history-limit`). ControllerRevisions that fall out of history are deleted. Each history entry copies those swiss annotations, `forceConflicts`, and `swiss.modelsphere.dev/action` when set. + +Events are emitted for Installed, Upgraded, Adopted, Drift, ApplyFailed, and Uninstalled. + +Charts are cached under `--llmservice-chart-cache-dir` (default `$TMPDIR/llm-operator-charts`), keyed by repository, name, and version. + +## Annotations + +| Annotation | Effect | +| --- | --- | +| `serving.modelsphere.dev/deletion-policy: Orphan` | On delete, drop the finalizer and do not uninstall. | +| `serving.modelsphere.dev/force-conflicts: ""` | Force field-manager conflicts on the apply for that generation only. Recorded in `status.history`. | +| `swiss.modelsphere.dev/*` | Copied into each history entry and ControllerRevision. Otherwise ignored. | + +## RBAC + +The manager role may manage `llmservices`, their status and finalizers, `controllerrevisions`, Helm release Secrets, and `events.k8s.io` events. It also includes the rules the sglang and vllm charts render (secrets, configmaps, services, serviceaccounts, deployments, pod disruption budgets, roles, role bindings, leases, pods, servicemonitors, leaderworksets, llmscalers, llmslorequirements, modelroutes) plus `namespaces` get, create, and patch. diff --git a/go.mod b/go.mod index 5057bf8..11ca8dd 100644 --- a/go.mod +++ b/go.mod @@ -3,98 +3,162 @@ module github.com/modelsphere/llm-operator go 1.26.0 require ( - github.com/onsi/ginkgo/v2 v2.27.4 - github.com/onsi/gomega v1.39.0 - k8s.io/api v0.36.0 - k8s.io/apimachinery v0.36.0 - k8s.io/client-go v0.36.0 + github.com/Masterminds/semver/v3 v3.5.0 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 + gopkg.in/yaml.v3 v3.0.1 + helm.sh/helm/v4 v4.2.4 + k8s.io/api v0.36.1 + k8s.io/apiextensions-apiserver v0.36.1 + k8s.io/apimachinery v0.36.1 + k8s.io/client-go v0.36.1 sigs.k8s.io/controller-runtime v0.24.1 ) require ( - cel.dev/expr v0.25.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + cel.dev/expr v0.25.2 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/extism/go-sdk v1.7.1 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fluxcd/cli-utils v1.2.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.1 // indirect + github.com/go-openapi/swag/loading v0.27.1 // indirect + github.com/go-openapi/swag/mangling v0.27.1 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.1 // indirect + github.com/go-openapi/swag/stringutils v0.27.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/cel-go v0.26.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.29.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lib/pq v1.12.3 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_golang v1.24.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.36.0 // indirect - k8s.io/apiserver v0.36.0 // indirect - k8s.io/component-base v0.36.0 // indirect + k8s.io/apiserver v0.36.1 // indirect + k8s.io/cli-runtime v0.36.1 // indirect + k8s.io/component-base v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/streaming v0.36.0 // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/kubectl v0.36.1 // indirect + k8s.io/streaming v0.37.0 // indirect + k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect + oras.land/oras-go/v2 v2.6.2 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 690c70d..26e92c3 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,110 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4= +github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0= +github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4= +github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= +github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch 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.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= +github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/cli-utils v1.2.1 h1:ug9CicKW7H9QXnvNDapTSKuryZvWcu4Nw7pRvQa6jDY= +github.com/fluxcd/cli-utils v1.2.1/go.mod h1:cky6M6eHvTQkoPtsuFYLIgAMYdpTCSLoor4IA6vueSw= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -43,22 +112,56 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -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.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= +github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM= +github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA= +github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 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/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= -github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= +github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -66,82 +169,150 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b h1:ogbOPx86mIhFy764gGkqnkFC8m5PJA7sPzlk9ppLVQA= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= 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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 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/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= -github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= +github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -152,66 +323,99 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= -golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -221,38 +425,52 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.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.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= -k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= -k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= -k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= -k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= -k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= -k8s.io/apiserver v0.36.0 h1:Jg5OFAENUACByUCg15CmhZAYrr5ZyJ+jodyA1mHl3YE= -k8s.io/apiserver v0.36.0/go.mod h1:mHvwdHf+qKEm+1/hYm756SV+oREOKSPnsjagOpx6Vho= -k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= -k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= -k8s.io/component-base v0.36.0 h1:hFjEktssxiJhrK1zfybkH4kJOi8iZuF+mIDCqS5+jRo= -k8s.io/component-base v0.36.0/go.mod h1:JZvIfcNHk+uck+8LhJzhSBtydWXaZNQwX2OdL+Mnwsk= +helm.sh/helm/v4 v4.2.4 h1:qIysMI0JpTC4WXf3AQ99V6rZGT0+gO0Ww8IOnnUnaZk= +helm.sh/helm/v4 v4.2.4/go.mod h1:ZP8nFdYe7jG1PTQelKzQXQ7m09/ruhMTrpDAf+OL5ms= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= +k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/apiserver v0.36.1 h1:iMS5V+rPUertv5P9RaqJgmHHTuh4quWpoxchvMUY+JY= +k8s.io/apiserver v0.36.1/go.mod h1:Cby1PbLWztu0GDOxoO6iFOyyqIsziHNEW+w9zVQ22Kw= +k8s.io/cli-runtime v0.36.1 h1:yuC/BGnnj1YYPh6D1P+pZnzinCs6DvMq86yAeNqoqzM= +k8s.io/cli-runtime v0.36.1/go.mod h1:ZQWHGt8xAF7KnviB79vX0lYNyUUqKIpU+LQg7exuFAw= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/component-base v0.36.1 h1:iG6GsELftXqTNG9HG6kiVjatSgAw1sf5pJ6R5a6N0kA= +k8s.io/component-base v0.36.1/go.mod h1:nf9XPlntRdqO6WMeEWAA5F93Y4ICZQdeT9GeqLDB3JI= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4= -k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/kubectl v0.36.1 h1:96HqS9twIdHM0MlJLTwbo14b9kUKPkOzZ4tlRDLv4qI= +k8s.io/kubectl v0.36.1/go.mod h1:/DGPAIewKsFWF9VFgGvkPhao2Ev4SNuE3BioZo8yPbk= +k8s.io/streaming v0.37.0 h1:iPBUZLZiKt5bV+lxJurASMOV07VuBhNpiwJt2//AWrM= +k8s.io/streaming v0.37.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 h1:/YpDJ4vReG7ZmzSpBGxduXgywWkJU9zHubgJG03MT+Y= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0/go.mod h1:tJo1aepTXyR+8Xs3sUsGBDk4Ub2AM5dPAPKJx0mpm5c= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/chart/repo.go b/internal/chart/repo.go new file mode 100644 index 0000000..cb2982a --- /dev/null +++ b/internal/chart/repo.go @@ -0,0 +1,288 @@ +// This file is a copy of swiss internal/chart, pending a shared importable package. +// Versions lists anonymously. VersionsAuth adds optional basic-auth credentials. +package chart + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +const maxIndex = 32 << 20 + +var client = &http.Client{Timeout: 30 * time.Second} + +// RepoError is a chart repository failing a read. Repo is the location as the +// site profile names it, not the URL that failed (a token endpoint, a page). +type RepoError struct { + Repo string + Status int // 0 when there was no response + Detail string // the repository's own message, when it sent one + Err error +} + +func (e *RepoError) Error() string { + var b strings.Builder + switch { + case e.NeedsLogin(): + // Registries answer 403 for a repository that does not exist too, so + // the message cannot tell the two apart. + fmt.Fprintf(&b, "%s refused an anonymous read (%d %s): it needs a login, which swissd does not have, or the chart is not there", + e.Repo, e.Status, http.StatusText(e.Status)) + case e.Status != 0: + fmt.Fprintf(&b, "%s: %d %s", e.Repo, e.Status, http.StatusText(e.Status)) + default: + fmt.Fprintf(&b, "%s: %v", e.Repo, e.Err) + } + if e.Detail != "" { + fmt.Fprintf(&b, " -- the repository says: %s", e.Detail) + } + return b.String() +} + +func (e *RepoError) Unwrap() error { return e.Err } + +func (e *RepoError) NeedsLogin() bool { + return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden +} + +// Credentials authenticates a chart repository listing. The zero value is anonymous. +type Credentials struct { + Username string + Password string +} + +// Versions lists the versions of chart name at the profile's chart source, +// anonymously: +// +// repo oci://host/path the registry's tags, read anonymously +// repo https://... the repository's index.yaml +// path the chart directory's own version +func Versions(ctx context.Context, repo, path, name string) ([]string, error) { + return VersionsAuth(ctx, repo, path, name, Credentials{}) +} + +// VersionsAuth is Versions with optional basic-auth credentials. Anonymous +// remains the default when creds is the zero value. +func VersionsAuth(ctx context.Context, repo, path, name string, creds Credentials) (vs []string, err error) { + var where string + defer func() { + var re *RepoError + if errors.As(err, &re) && re.Repo == "" { + re.Repo = where + } + }() + switch { + case path != "": + return localVersion(filepath.Join(path, name)) + case strings.HasPrefix(repo, "oci://"): + where = strings.TrimSuffix(repo, "/") + "/" + name + return ociTags(ctx, strings.TrimPrefix(where, "oci://"), creds) + case repo != "": + where = strings.TrimSuffix(repo, "/") + "/index.yaml" + return indexVersions(ctx, where, name, creds) + } + return nil, fmt.Errorf("no chart source: set chartRepo or chartPath in the site profile") +} + +func localVersion(dir string) ([]string, error) { + b, err := os.ReadFile(filepath.Join(dir, "Chart.yaml")) + if err != nil { + return nil, err + } + var c struct { + Version string `yaml:"version"` + } + if err := yaml.Unmarshal(b, &c); err != nil { + return nil, fmt.Errorf("%s/Chart.yaml: %w", dir, err) + } + return []string{c.Version}, nil +} + +func indexVersions(ctx context.Context, index, name string, creds Credentials) ([]string, error) { + body, _, err := get(ctx, index, "", creds) + if err != nil { + return nil, err + } + var idx struct { + Entries map[string][]struct { + Version string `yaml:"version"` + } `yaml:"entries"` + } + if err := yaml.Unmarshal(body, &idx); err != nil { + return nil, &RepoError{Err: fmt.Errorf("not a chart repository index: %w", err)} + } + entries, ok := idx.Entries[name] + if !ok { + return nil, fmt.Errorf("%s has no chart %q", index, name) + } + out := make([]string, len(entries)) + for i, e := range entries { + out[i] = e.Version + } + return out, nil +} + +func ociTags(ctx context.Context, ref string, creds Credentials) ([]string, error) { + host, repo, ok := strings.Cut(ref, "/") + if !ok { + return nil, fmt.Errorf("oci://%s names no repository", ref) + } + next := "https://" + host + "/v2/" + repo + "/tags/list" + var token string + var tags []string + for next != "" { + body, h, err := get(ctx, next, token, creds) + var re *RepoError + if errors.As(err, &re) && re.Status == http.StatusUnauthorized && token == "" { + if token, err = anonymousToken(ctx, h.Get("WWW-Authenticate"), creds); err != nil { + return nil, err + } + continue + } + if err != nil { + return nil, err + } + var page struct { + Tags []string `json:"tags"` + } + if err := json.Unmarshal(body, &page); err != nil { + return nil, &RepoError{Err: fmt.Errorf("not a tag list: %w", err)} + } + tags = append(tags, page.Tags...) + next = nextPage(next, h.Get("Link")) + } + // helm pushes a version's + as _, since a tag cannot carry one. + for i, t := range tags { + tags[i] = strings.ReplaceAll(t, "_", "+") + } + return tags, nil +} + +func anonymousToken(ctx context.Context, challenge string, creds Credentials) (string, error) { + scheme, params, _ := strings.Cut(challenge, " ") + if !strings.EqualFold(scheme, "Bearer") { + return "", &RepoError{Status: http.StatusUnauthorized, Detail: "it asks for " + scheme + " auth"} + } + p := map[string]string{} + for kv := range strings.SplitSeq(params, ",") { + k, v, _ := strings.Cut(strings.TrimSpace(kv), "=") + p[k] = strings.Trim(v, `"`) + } + realm, err := url.Parse(p["realm"]) + if err != nil || realm.Scheme == "" { + return "", &RepoError{Err: fmt.Errorf("its auth challenge names no token realm: %q", challenge)} + } + q := realm.Query() + for _, k := range []string{"service", "scope"} { + if p[k] != "" { + q.Set(k, p[k]) + } + } + realm.RawQuery = q.Encode() + body, _, err := get(ctx, realm.String(), "", creds) + if err != nil { + return "", err + } + var t struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &t); err != nil || (t.Token == "" && t.AccessToken == "") { + return "", &RepoError{Err: errors.New("its token endpoint returned no token")} + } + if t.Token != "" { + return t.Token, nil + } + return t.AccessToken, nil +} + +// nextPage follows a Link: <...>; rel="next" header, relative to the page. +func nextPage(page, link string) string { + if !strings.Contains(link, `rel="next"`) { + return "" + } + start, end := strings.Index(link, "<"), strings.Index(link, ">") + if start < 0 || end < start { + return "" + } + base, err := url.Parse(page) + if err != nil { + return "" + } + u, err := base.Parse(link[start+1 : end]) + if err != nil { + return "" + } + return u.String() +} + +// get reads u, failing with a *RepoError that carries the status and whatever +// the repository said about it. +func get(ctx context.Context, u, token string, creds Credentials) ([]byte, http.Header, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, nil, &RepoError{Err: err} + } + switch { + case token != "": + req.Header.Set("Authorization", "Bearer "+token) + case creds.Username != "" || creds.Password != "": + req.SetBasicAuth(creds.Username, creds.Password) + } + resp, err := client.Do(req) + if err != nil { + // Repo already names where; *url.Error would say it again. + var ue *url.Error + if errors.As(err, &ue) { + err = ue.Err + } + return nil, nil, &RepoError{Err: err} + } + defer func() { _ = resp.Body.Close() }() + b, err := io.ReadAll(io.LimitReader(resp.Body, maxIndex+1)) + if err != nil { + return nil, resp.Header, &RepoError{Status: resp.StatusCode, Err: err} + } + if resp.StatusCode != http.StatusOK { + return nil, resp.Header, &RepoError{Status: resp.StatusCode, Detail: detail(b)} + } + if len(b) > maxIndex { + return nil, resp.Header, &RepoError{Err: fmt.Errorf("%s is larger than %d bytes", u, maxIndex)} + } + return b, resp.Header, nil +} + +// detail is the message in an error body: the distribution API's errors[], or +// a short line of plain text. An HTML error page says nothing worth relaying. +func detail(body []byte) string { + var oci struct { + Errors []struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"errors"` + } + if json.Unmarshal(body, &oci) == nil && len(oci.Errors) > 0 { + msgs := make([]string, len(oci.Errors)) + for i, e := range oci.Errors { + msgs[i] = strings.TrimSpace(e.Code + ": " + e.Message) + } + return strings.Join(msgs, "; ") + } + s := strings.TrimSpace(string(body)) + if s == "" || len(s) > 200 || !utf8.ValidString(s) || strings.HasPrefix(s, "<") || strings.ContainsAny(s, "\n\r") { + return "" + } + return s +} diff --git a/internal/chart/repo_test.go b/internal/chart/repo_test.go new file mode 100644 index 0000000..00f06b8 --- /dev/null +++ b/internal/chart/repo_test.go @@ -0,0 +1,191 @@ +package chart + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestVersionsFromARepoIndex(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/charts/index.yaml" { + http.NotFound(w, r) + return + } + _, _ = fmt.Fprint(w, "apiVersion: v1\nentries:\n sglang:\n - version: 0.8.0\n - version: 0.7.1\n vllm:\n - version: 1.0.0\n") + })) + t.Cleanup(srv.Close) + + got, err := Versions(t.Context(), srv.URL+"/charts/", "", "sglang") + if err != nil || !reflect.DeepEqual(got, []string{v080, v071}) { + t.Fatalf("got %v, %v", got, err) + } + if _, err := Versions(t.Context(), srv.URL+"/charts", "", "nope"); err == nil || !strings.Contains(err.Error(), `no chart "nope"`) { + t.Errorf("a chart the index lacks: %v", err) + } +} + +// A public registry refuses the first read with a Bearer challenge and hands +// out an anonymous token; tags come back a page at a time. +func TestVersionsFromAnOCIRegistry(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == tokenPath: + if r.URL.Query().Get("scope") != "repository:org/charts/sglang:pull" { + http.Error(w, "bad scope "+r.URL.Query().Get("scope"), http.StatusBadRequest) + return + } + _, _ = fmt.Fprint(w, `{"token":"t"}`) + case r.Header.Get("Authorization") != "Bearer t": + w.Header().Set("WWW-Authenticate", `Bearer realm="`+srv.URL+`/token",service="reg",scope="repository:org/charts/sglang:pull"`) + w.WriteHeader(http.StatusUnauthorized) + case r.URL.Path == "/v2/org/charts/sglang/tags/list" && r.URL.Query().Get("last") == "": + w.Header().Set("Link", `; rel="next"`) + _, _ = fmt.Fprint(w, `{"tags":["0.7.0","0.7.1"]}`) + case r.URL.Path == "/v2/org/charts/sglang/tags/list": + _, _ = fmt.Fprint(w, `{"tags":["0.8.0_build.1","sha256-abc.sig"]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + saved := client + client = srv.Client() + t.Cleanup(func() { client = saved }) + + repo := "oci://" + strings.TrimPrefix(srv.URL, "https://") + "/org/charts" + got, err := Versions(t.Context(), repo, "", "sglang") + want := []string{"0.7.0", v071, "0.8.0+build.1", "sha256-abc.sig"} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, %v; want %v", got, err, want) + } +} + +func TestVersionsFromALocalChart(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sglang"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sglang", "Chart.yaml"), []byte("name: sglang\nversion: 0.7.4\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Versions(t.Context(), "https://ignored.example.com", dir, "sglang") + if err != nil || !reflect.DeepEqual(got, []string{"0.7.4"}) { + t.Fatalf("got %v, %v; want the chart directory's own version", got, err) + } +} + +// A private registry, or a chart that is not there: the token endpoint refuses, +// and the error says so in words, with what the registry said. +func TestARefusedReadSaysItNeedsALogin(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == tokenPath { + w.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(w, `{"errors":[{"code":"DENIED","message":"requested access to the resource is denied"}]}`) + return + } + w.Header().Set("WWW-Authenticate", `Bearer realm="`+srv.URL+`/token",service="reg",scope="repository:org/charts/sglang:pull"`) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + saved := client + client = srv.Client() + t.Cleanup(func() { client = saved }) + + repo := "oci://" + strings.TrimPrefix(srv.URL, "https://") + "/org/charts" + _, err := Versions(t.Context(), repo, "", "sglang") + var re *RepoError + if !errors.As(err, &re) || !re.NeedsLogin() { + t.Fatalf("got %v, want a RepoError needing a login", err) + } + for _, want := range []string{repo + "/sglang", "needs a login", "DENIED: requested access to the resource is denied"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if strings.Contains(err.Error(), tokenPath) { + t.Errorf("%q names the token endpoint rather than the repository", err) + } +} + +func TestARegistryAskingForBasicAuthNeedsALogin(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", `Basic realm="harbor"`) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + saved := client + client = srv.Client() + t.Cleanup(func() { client = saved }) + + _, err := Versions(t.Context(), "oci://"+strings.TrimPrefix(srv.URL, "https://")+"/org", "", "sglang") + var re *RepoError + if !errors.As(err, &re) || !re.NeedsLogin() || !strings.Contains(err.Error(), "Basic auth") { + t.Fatalf("got %v", err) + } +} + +func TestVersionsAuthSendsBasicAuth(t *testing.T) { + want := "Basic " + base64.StdEncoding.EncodeToString([]byte("user:s3cret")) + var tokenAuth string + var srv *httptest.Server + srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case tokenPath: + tokenAuth = r.Header.Get("Authorization") + if tokenAuth != want { + http.Error(w, "no basic", http.StatusUnauthorized) + return + } + _, _ = fmt.Fprint(w, `{"token":"t"}`) + case "/v2/org/sglang/tags/list": + if r.Header.Get("Authorization") != "Bearer t" { + w.Header().Set("WWW-Authenticate", `Bearer realm="`+srv.URL+`/token",service="reg",scope="repository:org/sglang:pull"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = fmt.Fprint(w, `{"tags":["0.8.0"]}`) + case "/charts/index.yaml": + if r.Header.Get("Authorization") != want { + http.Error(w, "no basic", http.StatusUnauthorized) + return + } + _, _ = fmt.Fprint(w, "apiVersion: v1\nentries:\n sglang:\n - version: 0.8.0\n") + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + saved := client + client = srv.Client() + t.Cleanup(func() { client = saved }) + + creds := Credentials{Username: "user", Password: "s3cret"} + idx, err := VersionsAuth(t.Context(), "https://"+strings.TrimPrefix(srv.URL, "https://")+"/charts", "", "sglang", creds) + if err != nil || !reflect.DeepEqual(idx, []string{v080}) { + t.Fatalf("index: got %v, %v", idx, err) + } + repo := "oci://" + strings.TrimPrefix(srv.URL, "https://") + "/org" + got, err := VersionsAuth(t.Context(), repo, "", "sglang", creds) + if err != nil || !reflect.DeepEqual(got, []string{v080}) || tokenAuth != want { + t.Fatalf("oci: got %v, %v; token auth %q", got, err, tokenAuth) + } +} + +func TestAMissingIndexIsReportedByStatus(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + _, err := Versions(t.Context(), srv.URL+"/charts", "", "sglang") + if err == nil || err.Error() != srv.URL+"/charts/index.yaml: 404 Not Found -- the repository says: 404 page not found" { + t.Errorf("got %v", err) + } +} diff --git a/internal/chart/version.go b/internal/chart/version.go new file mode 100644 index 0000000..bbe9c34 --- /dev/null +++ b/internal/chart/version.go @@ -0,0 +1,128 @@ +// Package chart resolves a chart version, one version or a range, to the +// version a release records. +// +// This is a copy of swiss internal/chart, pending a shared importable package. +package chart + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/Masterminds/semver/v3" +) + +// Spec is a catalog's chart.version in helm's constraint syntax: +// +// 0.7.1 exactly 0.7.1 +// >=0.7.1 0.7.1 or newer +// ^0.7.1, ~0.7.1 >=0.7.1 <0.8.0 +// >=0.7.1 <0.9.0 both +// +// Prereleases match only a range that names one, as in helm. +type Spec struct { + raw string + c *semver.Constraints + exact *semver.Version +} + +func ParseSpec(s string) (Spec, error) { + s = strings.TrimSpace(s) + if s == "" { + return Spec{}, fmt.Errorf("no chart version") + } + c, err := semver.NewConstraint(s) + if err != nil { + return Spec{}, fmt.Errorf("chart version %q is neither a version nor a range: %w", s, err) + } + sp := Spec{raw: s, c: c} + if v, err := semver.StrictNewVersion(s); err == nil { + sp.exact = v + } + return sp, nil +} + +func (s Spec) String() string { return s.raw } + +// Exact is the one version a bare spec names, or "" for a range. +func (s Spec) Exact() string { + if s.exact == nil { + return "" + } + return s.exact.Original() +} + +func (s Spec) Allows(v string) bool { + sv, err := semver.StrictNewVersion(v) + return err == nil && s.c.Check(sv) +} + +// Match is the versions in have that s allows, newest first. Tags that are not +// versions are skipped. +func (s Spec) Match(have []string) []string { + var vs []*semver.Version + seen := map[string]bool{} + for _, h := range have { + v, err := semver.StrictNewVersion(h) + if err != nil || !s.c.Check(v) || seen[v.Original()] { + continue + } + seen[v.Original()] = true + vs = append(vs, v) + } + slices.SortFunc(vs, func(a, b *semver.Version) int { return b.Compare(a) }) + out := make([]string, len(vs)) + for i, v := range vs { + out[i] = v.Original() + } + return out +} + +type Lister func(ctx context.Context) ([]string, error) + +// ListError is a range that could not be resolved because its chart repository +// could not be listed. Not the caller's mistake, and naming a version gets past it. +type ListError struct { + Spec string + Err error +} + +func (e *ListError) Error() string { + return fmt.Sprintf("cannot resolve chart version %q: %v. Name a chart version to deploy without listing the repository", e.Spec, e.Err) +} + +func (e *ListError) Unwrap() error { return e.Err } + +// Resolve picks the version a plan records; the first that applies wins: +// +// want named by the caller, and in s +// exact s is a bare version +// keep the release's running version, while s allows it +// list the newest version in s the repository holds +func Resolve(ctx context.Context, spec Spec, want, keep string, list Lister) (string, error) { + if want != "" { + if !spec.Allows(want) { + return "", fmt.Errorf("chart version %q is not in the catalog's %q", want, spec) + } + return want, nil + } + if v := spec.Exact(); v != "" { + return v, nil + } + if keep != "" && spec.Allows(keep) { + return keep, nil + } + if list == nil { + return "", &ListError{Spec: spec.String(), Err: fmt.Errorf("no chart repository to list")} + } + have, err := list(ctx) + if err != nil { + return "", &ListError{Spec: spec.String(), Err: err} + } + m := spec.Match(have) + if len(m) == 0 { + return "", fmt.Errorf("no chart version satisfies %q: the repository has %d versions, none in range", spec, len(have)) + } + return m[0], nil +} diff --git a/internal/chart/version_test.go b/internal/chart/version_test.go new file mode 100644 index 0000000..1892948 --- /dev/null +++ b/internal/chart/version_test.go @@ -0,0 +1,102 @@ +package chart + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +const ( + v071 = "0.7.1" + v072 = "0.7.2" + v080 = "0.8.0" + tokenPath = "/token" +) + +func spec(t *testing.T, s string) Spec { + t.Helper() + sp, err := ParseSpec(s) + if err != nil { + t.Fatal(err) + } + return sp +} + +// A bare version is that version and nothing else, as before ranges. +func TestABareVersionIsExact(t *testing.T) { + sp := spec(t, v071) + if sp.Exact() != v071 { + t.Errorf("Exact = %q, want 0.7.1", sp.Exact()) + } + if sp.Allows(v072) || !sp.Allows(v071) { + t.Error("0.7.1 must allow 0.7.1 and only it") + } + if spec(t, ">=0.7.1").Exact() != "" { + t.Error("a range names no one version") + } +} + +func TestParseSpecRefusesWhatIsNotAVersion(t *testing.T) { + for _, s := range []string{"", "latest", ">=banana"} { + if _, err := ParseSpec(s); err == nil { + t.Errorf("%q: want an error", s) + } + } +} + +func TestMatchIsNewestFirstAndSkipsNonVersions(t *testing.T) { + got := spec(t, ">=0.7.1").Match([]string{"0.7.0", v071, "0.10.0", "latest", v080, "0.9.0-rc1", v080}) + want := []string{"0.10.0", v080, v071} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v -- newest first, no prerelease, no duplicates", got, want) + } + if got := spec(t, "^0.7.1").Match([]string{v071, "0.7.9", v080}); !reflect.DeepEqual(got, []string{"0.7.9", v071}) { + t.Errorf("^0.7.1 matched %v, want 0.7.x only", got) + } +} + +func list(vs ...string) Lister { + return func(context.Context) ([]string, error) { return vs, nil } +} + +func noList(t *testing.T) Lister { + return func(context.Context) ([]string, error) { + t.Error("the repository must not be read") + return nil, errors.New("read") + } +} + +func TestResolve(t *testing.T) { + ctx := t.Context() + for _, tc := range []struct { + name, spec, want, keep, got string + list Lister + }{ + {name: "named, in range", spec: ">=0.7.1", want: "0.7.3", got: "0.7.3", list: noList(t)}, + {name: "exact needs no registry", spec: v071, got: v071, list: noList(t)}, + {name: "exact moves a running one off", spec: v072, keep: v071, got: v072, list: noList(t)}, + {name: "running one kept while in range", spec: ">=0.7.1", keep: v072, got: v072, list: noList(t)}, + {name: "running one out of range", spec: ">=0.8.0", keep: v072, got: "0.9.0", list: list(v072, v080, "0.9.0")}, + {name: "newest in range", spec: ">=0.7.1 <0.9.0", got: "0.8.1", list: list(v071, "0.8.1", "0.9.0")}, + } { + got, err := Resolve(ctx, spec(t, tc.spec), tc.want, tc.keep, tc.list) + if err != nil || got != tc.got { + t.Errorf("%s: got %q, %v; want %q", tc.name, got, err, tc.got) + } + } +} + +func TestResolveRefuses(t *testing.T) { + ctx := t.Context() + if _, err := Resolve(ctx, spec(t, ">=0.7.1"), "0.6.0", "", noList(t)); err == nil || !strings.Contains(err.Error(), "not in the catalog's") { + t.Errorf("a named version out of range: %v", err) + } + if _, err := Resolve(ctx, spec(t, ">=0.7.1"), "", "", list("0.6.0")); err == nil || !strings.Contains(err.Error(), "no chart version satisfies") { + t.Errorf("nothing in range: %v", err) + } + if _, err := Resolve(ctx, spec(t, ">=0.7.1"), "", "", nil); err == nil || !strings.Contains(err.Error(), "Name a chart version") { + t.Errorf("a range with nowhere to list: %v", err) + } +} diff --git a/internal/controller/customprovider.go b/internal/controller/autoscaling/customprovider.go similarity index 100% rename from internal/controller/customprovider.go rename to internal/controller/autoscaling/customprovider.go diff --git a/internal/controller/customprovider_test.go b/internal/controller/autoscaling/customprovider_test.go similarity index 99% rename from internal/controller/customprovider_test.go rename to internal/controller/autoscaling/customprovider_test.go index 56d476f..386d0cd 100644 --- a/internal/controller/customprovider_test.go +++ b/internal/controller/autoscaling/customprovider_test.go @@ -26,7 +26,7 @@ import ( "k8s.io/apimachinery/pkg/types" - autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/v1alpha1" + autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/autoscaling/v1alpha1" ) const ( diff --git a/internal/controller/llmscaler_controller.go b/internal/controller/autoscaling/llmscaler_controller.go similarity index 99% rename from internal/controller/llmscaler_controller.go rename to internal/controller/autoscaling/llmscaler_controller.go index de7778a..bc022ec 100644 --- a/internal/controller/llmscaler_controller.go +++ b/internal/controller/autoscaling/llmscaler_controller.go @@ -43,7 +43,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" - autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/v1alpha1" + autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/autoscaling/v1alpha1" ) const ( diff --git a/internal/controller/llmscaler_controller_test.go b/internal/controller/autoscaling/llmscaler_controller_test.go similarity index 99% rename from internal/controller/llmscaler_controller_test.go rename to internal/controller/autoscaling/llmscaler_controller_test.go index a387b87..c12fe30 100644 --- a/internal/controller/llmscaler_controller_test.go +++ b/internal/controller/autoscaling/llmscaler_controller_test.go @@ -33,7 +33,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" - autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/v1alpha1" + autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/autoscaling/v1alpha1" ) // Literals shared across the fixtures in this package's tests. diff --git a/internal/controller/suite_test.go b/internal/controller/autoscaling/suite_test.go similarity index 93% rename from internal/controller/suite_test.go rename to internal/controller/autoscaling/suite_test.go index 9c7b26a..190e224 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/autoscaling/suite_test.go @@ -33,7 +33,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/v1alpha1" + autoscalingv1alpha1 "github.com/modelsphere/llm-operator/api/autoscaling/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -67,7 +67,7 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, } @@ -103,7 +103,7 @@ var _ = AfterSuite(func() { // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are // properly set up, run 'make setup-envtest' beforehand. func getFirstFoundEnvTestBinaryDir() string { - basePath := filepath.Join("..", "..", "bin", "k8s") + basePath := filepath.Join("..", "..", "..", "bin", "k8s") entries, err := os.ReadDir(basePath) if err != nil { logf.Log.Error(err, "Failed to read directory", "path", basePath) diff --git a/internal/controller/serving/llmservice_controller.go b/internal/controller/serving/llmservice_controller.go new file mode 100644 index 0000000..a9eacf6 --- /dev/null +++ b/internal/controller/serving/llmservice_controller.go @@ -0,0 +1,473 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serving + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" + "github.com/modelsphere/llm-operator/internal/chart" + "github.com/modelsphere/llm-operator/internal/helm" +) + +const ( + llmserviceLabel = "serving.modelsphere.dev/llmservice" + credentialUsernameKey = "username" + credentialPasswordKey = "password" + actionAnnotation = servingv1alpha1.SwissAnnotationPrefix + "action" +) + +// VersionLister lists chart versions for spec.chart. Tests set it. +// A nil lister reads the repository with chart.VersionsAuth. +type VersionLister func(ctx context.Context, repo, name string, creds *helm.Credentials) ([]string, error) + +// LLMServiceReconciler reconciles a LLMService object. +type LLMServiceReconciler struct { + client.Client + Scheme *runtime.Scheme + Helm helm.Helm + Recorder events.EventRecorder + HistoryLimit int + ListVersions VersionLister +} + +// +kubebuilder:rbac:groups=serving.modelsphere.dev,resources=llmservices,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=serving.modelsphere.dev,resources=llmservices/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=serving.modelsphere.dev,resources=llmservices/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=controllerrevisions;deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=secrets;configmaps;services;serviceaccounts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;create;patch +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=leaderworkerset.x-k8s.io,resources=leaderworkersets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=autoscaling.4pd.io;autoscaling.modelsphere.dev,resources=llmscalers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=inference.x-k8s.io;inference.modelsphere.dev,resources=llmslorequirements,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=routing.gpucluster.io;routing.modelsphere.dev,resources=modelroutes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch + +// Reconcile applies spec to one helm release, or adopts a release the operator did not install. +func (r *LLMServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var obj servingv1alpha1.LLMService + if err := r.Get(ctx, req.NamespacedName, &obj); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + // Suspend wins over deletion: a suspended object is left as it is, including its finalizer. + if obj.Spec.Suspend { + return ctrl.Result{}, nil + } + if r.Helm == nil { + return ctrl.Result{}, fmt.Errorf("helm client is not configured") + } + if !obj.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, &obj) + } + if err := r.ensureFinalizer(ctx, &obj); err != nil { + return ctrl.Result{}, err + } + return r.reconcileApply(ctx, &obj) +} + +func (r *LLMServiceReconciler) reconcileDelete(ctx context.Context, obj *servingv1alpha1.LLMService) (ctrl.Result, error) { + if obj.Annotations[servingv1alpha1.AnnotationDeletionPolicy] != servingv1alpha1.DeletionPolicyOrphan { + if err := r.Helm.Uninstall(ctx, obj.Namespace, obj.Name); err != nil { + return ctrl.Result{}, err + } + r.event(obj, corev1.EventTypeNormal, "Uninstalled", "Uninstalled helm release") + } + if !controllerutil.ContainsFinalizer(obj, servingv1alpha1.Finalizer) { + return ctrl.Result{}, nil + } + controllerutil.RemoveFinalizer(obj, servingv1alpha1.Finalizer) + if err := r.Update(ctx, obj); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *LLMServiceReconciler) ensureFinalizer(ctx context.Context, obj *servingv1alpha1.LLMService) error { + if controllerutil.ContainsFinalizer(obj, servingv1alpha1.Finalizer) { + return nil + } + controllerutil.AddFinalizer(obj, servingv1alpha1.Finalizer) + return r.Update(ctx, obj) +} + +func (r *LLMServiceReconciler) reconcileApply(ctx context.Context, obj *servingv1alpha1.LLMService) (ctrl.Result, error) { + creds, err := r.credentials(ctx, obj) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonChartResolveFailed, err, true) + } + spec, err := chart.ParseSpec(obj.Spec.Chart.Version) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonChartResolveFailed, err, true) + } + keep := "" + if obj.Status.Chart.Name == obj.Spec.Chart.Name { + keep = obj.Status.Chart.Version + } + ver, err := chart.Resolve(ctx, spec, "", keep, func(ctx context.Context) ([]string, error) { + return r.listVersions(ctx, obj.Spec.Chart.Repo, obj.Spec.Chart.Name, creds) + }) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonChartResolveFailed, err, true) + } + values, err := mergeLayers(obj.Spec.Layers) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonFailed, err, true) + } + hash, err := appliedHash(obj.Spec.Chart.Name, ver, values) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonFailed, err, true) + } + if hash == obj.Status.AppliedHash { + return r.finishApplied(ctx, obj) + } + rel, err := r.Helm.Get(ctx, obj.Namespace, obj.Name) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonApplyFailed, err, true) + } + if rel != nil && obj.Status.AppliedHash == "" && len(obj.Status.History) == 0 { + return r.adopt(ctx, obj, ver, values, hash, rel) + } + return r.apply(ctx, obj, ver, values, hash, creds, rel) +} + +func (r *LLMServiceReconciler) adopt(ctx context.Context, obj *servingv1alpha1.LLMService, ver string, values map[string]any, hash string, rel *helm.Release) (ctrl.Result, error) { + chartOK := rel.ChartName == obj.Spec.Chart.Name && rel.ChartVersion == ver + valuesOK, err := jsonEqual(rel.Config, values) + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonApplyFailed, err, true) + } + if chartOK && valuesOK { + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: servingv1alpha1.ConditionAdopted, + Status: metav1.ConditionTrue, + Reason: servingv1alpha1.ReasonAdopted, + ObservedGeneration: obj.Generation, + }) + if err := r.recordSuccess(ctx, obj, rel, obj.Spec.Chart.Name, ver, hash, servingv1alpha1.ReasonAdopted, false); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + msg := driftMessage(obj.Spec.Chart.Name, ver, rel, !valuesOK) + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: servingv1alpha1.ConditionApplied, + Status: metav1.ConditionFalse, + Reason: servingv1alpha1.ReasonDrift, + Message: msg, + ObservedGeneration: obj.Generation, + }) + // Drift does not requeue. The next spec change reconciles again. + return r.fail(ctx, obj, servingv1alpha1.ConditionAdopted, servingv1alpha1.ReasonDrift, errors.New(msg), false) +} + +func (r *LLMServiceReconciler) apply(ctx context.Context, obj *servingv1alpha1.LLMService, ver string, values map[string]any, hash string, creds *helm.Credentials, existing *helm.Release) (ctrl.Result, error) { + obj.Status.Phase = servingv1alpha1.PhaseApplying + if err := r.updateStatus(ctx, obj); err != nil { + return ctrl.Result{}, err + } + ref := helm.ChartRef{Repo: obj.Spec.Chart.Repo, Name: obj.Spec.Chart.Name, Version: ver} + opts := helm.ApplyOpts{ + ForceConflicts: obj.Annotations[servingv1alpha1.AnnotationForceConflicts] == strconv.FormatInt(obj.Generation, 10), + Credentials: creds, + } + var ( + rel *helm.Release + err error + reason string + ) + if existing == nil { + rel, err = r.Helm.Install(ctx, obj.Namespace, obj.Name, ref, values, opts) + reason = servingv1alpha1.ReasonInstalled + } else { + rel, err = r.Helm.Upgrade(ctx, obj.Namespace, obj.Name, ref, values, opts) + reason = servingv1alpha1.ReasonUpgraded + } + if err != nil { + return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonApplyFailed, err, true) + } + if err := r.recordSuccess(ctx, obj, rel, obj.Spec.Chart.Name, ver, hash, reason, opts.ForceConflicts); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *LLMServiceReconciler) finishApplied(ctx context.Context, obj *servingv1alpha1.LLMService) (ctrl.Result, error) { + cond := meta.FindStatusCondition(obj.Status.Conditions, servingv1alpha1.ConditionApplied) + changed := obj.Status.Phase != servingv1alpha1.PhaseApplied || + obj.Status.ObservedGeneration != obj.Generation || + obj.Status.Message != "" || + cond == nil || cond.Status != metav1.ConditionTrue + if changed { + if cond == nil || cond.Status != metav1.ConditionTrue { + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: servingv1alpha1.ConditionApplied, + Status: metav1.ConditionTrue, + Reason: servingv1alpha1.ReasonInstalled, + ObservedGeneration: obj.Generation, + }) + } + obj.Status.Phase = servingv1alpha1.PhaseApplied + obj.Status.Message = "" + obj.Status.ObservedGeneration = obj.Generation + if err := r.updateStatus(ctx, obj); err != nil { + return ctrl.Result{}, err + } + } + if err := r.gcRevisions(ctx, obj); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +type revisionSnapshot struct { + Spec servingv1alpha1.LLMServiceSpec `json:"spec"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +func (r *LLMServiceReconciler) recordSuccess(ctx context.Context, obj *servingv1alpha1.LLMService, rel *helm.Release, chartName, chartVersion, hash, reason string, force bool) error { + snap, err := json.Marshal(revisionSnapshot{Spec: obj.Spec, Annotations: swissAnnotations(obj.Annotations)}) + if err != nil { + return err + } + crName := controllerRevisionName(obj.Name, hash) + if err := r.writeRevision(ctx, obj, crName, snap, rel.Revision); err != nil { + return err + } + ann := swissAnnotations(obj.Annotations) + entry := servingv1alpha1.HistoryEntry{ + Revision: rel.Revision, + Hash: hash, + AppliedAt: metav1.Now(), + ControllerRevision: crName, + Annotations: ann, + ForceConflicts: force, + Action: obj.Annotations[actionAnnotation], + } + obj.Status.History = append([]servingv1alpha1.HistoryEntry{entry}, obj.Status.History...) + if limit := r.historyLimit(); len(obj.Status.History) > limit { + obj.Status.History = obj.Status.History[:limit] + } + obj.Status.AppliedHash = hash + obj.Status.Chart = servingv1alpha1.ChartStatus{Name: chartName, Version: chartVersion} + obj.Status.Helm = servingv1alpha1.HelmStatus{Revision: rel.Revision, Status: rel.Status} + obj.Status.Phase = servingv1alpha1.PhaseApplied + obj.Status.Message = "" + obj.Status.ObservedGeneration = obj.Generation + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: servingv1alpha1.ConditionApplied, + Status: metav1.ConditionTrue, + Reason: reason, + ObservedGeneration: obj.Generation, + }) + if err := r.updateStatus(ctx, obj); err != nil { + return err + } + r.event(obj, corev1.EventTypeNormal, reason, reason+" helm release") + return r.gcRevisions(ctx, obj) +} + +func (r *LLMServiceReconciler) writeRevision(ctx context.Context, obj *servingv1alpha1.LLMService, name string, data []byte, rev int) error { + cr := &appsv1.ControllerRevision{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: obj.Namespace}} + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, cr, func() error { + if cr.Labels == nil { + cr.Labels = map[string]string{} + } + cr.Labels[llmserviceLabel] = obj.Name + cr.Data = runtime.RawExtension{Raw: append([]byte(nil), data...)} + cr.Revision = int64(rev) + return controllerutil.SetControllerReference(obj, cr, r.Scheme) + }) + return err +} + +func (r *LLMServiceReconciler) gcRevisions(ctx context.Context, obj *servingv1alpha1.LLMService) error { + var list appsv1.ControllerRevisionList + if err := r.List(ctx, &list, client.InNamespace(obj.Namespace), client.MatchingLabels{llmserviceLabel: obj.Name}); err != nil { + return err + } + keep := map[string]struct{}{} + for _, h := range obj.Status.History { + keep[h.ControllerRevision] = struct{}{} + } + for i := range list.Items { + item := &list.Items[i] + if _, ok := keep[item.Name]; ok { + continue + } + if err := r.Delete(ctx, item); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + return nil +} + +func (r *LLMServiceReconciler) fail(ctx context.Context, obj *servingv1alpha1.LLMService, condType, reason string, cause error, requeue bool) (ctrl.Result, error) { + logf.FromContext(ctx).Error(cause, "Failed to reconcile LLMService", "reason", reason) + obj.Status.Phase = servingv1alpha1.PhaseFailed + obj.Status.Message = cause.Error() + obj.Status.ObservedGeneration = obj.Generation + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: condType, + Status: metav1.ConditionFalse, + Reason: reason, + Message: cause.Error(), + ObservedGeneration: obj.Generation, + }) + if err := r.updateStatus(ctx, obj); err != nil { + return ctrl.Result{}, err + } + if reason == servingv1alpha1.ReasonApplyFailed || reason == servingv1alpha1.ReasonDrift { + r.event(obj, corev1.EventTypeWarning, reason, cause.Error()) + } + if requeue { + return ctrl.Result{}, cause + } + return ctrl.Result{}, nil +} + +func (r *LLMServiceReconciler) updateStatus(ctx context.Context, obj *servingv1alpha1.LLMService) error { + status := obj.Status + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + var latest servingv1alpha1.LLMService + if err := r.Get(ctx, client.ObjectKeyFromObject(obj), &latest); err != nil { + return err + } + latest.Status = status + return r.Status().Update(ctx, &latest) + }) +} + +func (r *LLMServiceReconciler) credentials(ctx context.Context, obj *servingv1alpha1.LLMService) (*helm.Credentials, error) { + ref := obj.Spec.Chart.CredentialsRef + if ref == nil || ref.Name == "" { + return nil, nil + } + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: obj.Namespace, Name: ref.Name}, &secret); err != nil { + return nil, fmt.Errorf("credentials secret %s: %w", ref.Name, err) + } + user, hasUser := secret.Data[credentialUsernameKey] + pass, hasPass := secret.Data[credentialPasswordKey] + if !hasUser || !hasPass { + return nil, fmt.Errorf("credentials secret %s must contain username and password", ref.Name) + } + return &helm.Credentials{Username: string(user), Password: string(pass)}, nil +} + +func (r *LLMServiceReconciler) listVersions(ctx context.Context, repo, name string, creds *helm.Credentials) ([]string, error) { + if r.ListVersions != nil { + return r.ListVersions(ctx, repo, name, creds) + } + var c chart.Credentials + if creds != nil { + c = chart.Credentials{Username: creds.Username, Password: creds.Password} + } + return chart.VersionsAuth(ctx, repo, "", name, c) +} + +func (r *LLMServiceReconciler) historyLimit() int { + if r.HistoryLimit <= 0 { + return servingv1alpha1.DefaultHistoryLimit + } + return r.HistoryLimit +} + +func (r *LLMServiceReconciler) event(obj client.Object, eventType, reason, msg string) { + if r.Recorder == nil { + return + } + // The note is a %s argument so a helm error containing % is not treated as a format. + r.Recorder.Eventf(obj, nil, eventType, reason, reason, "%s", msg) +} + +func (r *LLMServiceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&servingv1alpha1.LLMService{}, builder.WithPredicates(llmServicePredicate{})). + Named("serving-llmservice"). + Complete(r) +} + +func swissAnnotations(in map[string]string) map[string]string { + var out map[string]string + for k, v := range in { + if !strings.HasPrefix(k, servingv1alpha1.SwissAnnotationPrefix) { + continue + } + if out == nil { + out = map[string]string{} + } + out[k] = v + } + return out +} + +func controllerRevisionName(svc, hash string) string { + short := strings.TrimPrefix(hash, "sha256:") + if len(short) > 8 { + short = short[:8] + } + suffix := "-" + short + base := svc + const maxLen = 253 + if len(base)+len(suffix) > maxLen { + base = base[:maxLen-len(suffix)] + base = strings.TrimRight(base, "-.") + } + return base + suffix +} + +func driftMessage(name, version string, rel *helm.Release, valuesDiffer bool) string { + var b strings.Builder + b.WriteString("release drifted: ") + wrote := false + if rel.ChartName != name || rel.ChartVersion != version { + fmt.Fprintf(&b, "chart is %s %s, release has %s %s", name, version, rel.ChartName, rel.ChartVersion) + wrote = true + } + if valuesDiffer { + if wrote { + b.WriteString("; ") + } + b.WriteString("values differ") + } + return b.String() +} diff --git a/internal/controller/serving/llmservice_controller_test.go b/internal/controller/serving/llmservice_controller_test.go new file mode 100644 index 0000000..a3a428f --- /dev/null +++ b/internal/controller/serving/llmservice_controller_test.go @@ -0,0 +1,443 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serving + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "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" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" + "github.com/modelsphere/llm-operator/internal/helm" +) + +const ( + ver086 = "0.8.6" + deployed = "deployed" +) + +var nsSeq int + +func takeNS() string { + nsSeq++ + name := fmt.Sprintf("ns-%d", nsSeq) + Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}})).To(Succeed()) + return name +} + +func layer(body string) servingv1alpha1.Layer { + return servingv1alpha1.Layer{Name: "site", Values: &apiextensionsv1.JSON{Raw: []byte(body)}} +} + +func newService(ns, name, version string) *servingv1alpha1.LLMService { + return &servingv1alpha1.LLMService{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ns, + Annotations: map[string]string{ + "swiss.modelsphere.dev/action": "install", + "swiss.modelsphere.dev/note": "n", + "unrelated": "x", + }, + }, + Spec: servingv1alpha1.LLMServiceSpec{ + Chart: servingv1alpha1.ChartSpec{ + Name: "sglang", + Repo: "oci://ghcr.io/modelsphere/charts", + Version: version, + }, + Layers: []servingv1alpha1.Layer{layer(`{"replicaCount":1}`)}, + }, + } +} + +type fakeHelm struct { + releases map[string]*helm.Release + gets, installs, upgrades int + uninstalls int + installErr error + lastForce bool +} + +func (f *fakeHelm) slot(ns, name string) string { return ns + "/" + name } + +func (f *fakeHelm) Get(_ context.Context, ns, name string) (*helm.Release, error) { + f.gets++ + rel := f.releases[f.slot(ns, name)] + if rel == nil { + return nil, nil + } + cp := *rel + return &cp, nil +} + +func (f *fakeHelm) Install(_ context.Context, ns, name string, ch helm.ChartRef, values map[string]any, opts helm.ApplyOpts) (*helm.Release, error) { + f.installs++ + f.lastForce = opts.ForceConflicts + if f.installErr != nil { + return nil, f.installErr + } + rel := &helm.Release{Revision: 1, Status: deployed, ChartName: ch.Name, ChartVersion: ch.Version, Config: values} + f.put(ns, name, rel) + cp := *rel + return &cp, nil +} + +func (f *fakeHelm) Upgrade(_ context.Context, ns, name string, ch helm.ChartRef, values map[string]any, opts helm.ApplyOpts) (*helm.Release, error) { + f.upgrades++ + f.lastForce = opts.ForceConflicts + if f.installErr != nil { + return nil, f.installErr + } + rev := 1 + if prev := f.releases[f.slot(ns, name)]; prev != nil { + rev = prev.Revision + 1 + } + rel := &helm.Release{Revision: rev, Status: deployed, ChartName: ch.Name, ChartVersion: ch.Version, Config: values} + f.put(ns, name, rel) + cp := *rel + return &cp, nil +} + +func (f *fakeHelm) Uninstall(_ context.Context, ns, name string) error { + f.uninstalls++ + delete(f.releases, f.slot(ns, name)) + return nil +} + +func (f *fakeHelm) put(ns, name string, rel *helm.Release) { + if f.releases == nil { + f.releases = map[string]*helm.Release{} + } + f.releases[f.slot(ns, name)] = rel +} + +type versionList struct { + versions []string + calls int + err error +} + +func (v *versionList) list(context.Context, string, string, *helm.Credentials) ([]string, error) { + v.calls++ + if v.err != nil { + return nil, v.err + } + return append([]string(nil), v.versions...), nil +} + +func newReconciler(h *fakeHelm, l *versionList, limit int) *LLMServiceReconciler { + return &LLMServiceReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Helm: h, + Recorder: events.NewFakeRecorder(32), + HistoryLimit: limit, + ListVersions: l.list, + } +} + +func doReconcile(r *LLMServiceReconciler, ns, name string) (reconcile.Result, error) { + return r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: name}}) +} + +func fetch(ns, name string) *servingv1alpha1.LLMService { + obj := &servingv1alpha1.LLMService{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, obj)).To(Succeed()) + return obj +} + +func condition(obj *servingv1alpha1.LLMService, typ string) *metav1.Condition { + return meta.FindStatusCondition(obj.Status.Conditions, typ) +} + +var _ = Describe("LLMService controller", func() { + It("installs and records a ControllerRevision", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + obj := newService(ns, "qwen", ">=0.8.0") + Expect(k8sClient.Create(ctx, obj)).To(Succeed()) + + _, err := doReconcile(newReconciler(h, l, 10), ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(1)) + Expect(h.upgrades).To(Equal(0)) + + got := fetch(ns, "qwen") + Expect(got.Finalizers).To(ContainElement(servingv1alpha1.Finalizer)) + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseApplied)) + Expect(got.Status.Chart.Version).To(Equal(ver086)) + Expect(got.Status.AppliedHash).To(HavePrefix("sha256:")) + Expect(got.Status.Helm.Revision).To(Equal(1)) + Expect(got.Status.ObservedGeneration).To(Equal(got.Generation)) + Expect(got.Status.History).To(HaveLen(1)) + Expect(got.Status.History[0].Action).To(Equal("install")) + Expect(got.Status.History[0].Annotations).To(HaveKey("swiss.modelsphere.dev/note")) + Expect(got.Status.History[0].Annotations).NotTo(HaveKey("unrelated")) + Expect(condition(got, servingv1alpha1.ConditionApplied).Status).To(Equal(metav1.ConditionTrue)) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonInstalled)) + + var cr appsv1.ControllerRevision + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: got.Status.History[0].ControllerRevision}, &cr)).To(Succeed()) + Expect(cr.Labels).To(HaveKeyWithValue(llmserviceLabel, "qwen")) + Expect(cr.OwnerReferences).NotTo(BeEmpty()) + Expect(cr.OwnerReferences[0].Controller).NotTo(BeNil()) + Expect(*cr.OwnerReferences[0].Controller).To(BeTrue()) + var snap revisionSnapshot + Expect(json.Unmarshal(cr.Data.Raw, &snap)).To(Succeed()) + Expect(snap.Annotations).To(HaveKeyWithValue("swiss.modelsphere.dev/action", "install")) + Expect(snap.Annotations).NotTo(HaveKey("unrelated")) + Expect(snap.Spec.Chart.Name).To(Equal("sglang")) + }) + + It("does not call helm when the spec is unchanged", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + + got := fetch(ns, "qwen") + got.Annotations["swiss.modelsphere.dev/note"] = "later" + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(1)) + Expect(h.upgrades).To(Equal(0)) + Expect(h.gets).To(Equal(1)) + }) + + It("upgrades and bounds history", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 2) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + first := fetch(ns, "qwen").Status.History[0].ControllerRevision + + for _, body := range []string{`{"replicaCount":2}`, `{"replicaCount":3}`} { + got := fetch(ns, "qwen") + got.Spec.Layers = []servingv1alpha1.Layer{layer(body)} + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + } + Expect(h.upgrades).To(Equal(2)) + got := fetch(ns, "qwen") + Expect(got.Status.History).To(HaveLen(2)) + Expect(got.Status.History[0].Revision).To(Equal(3)) + Expect(got.Status.History[1].Revision).To(Equal(2)) + Expect(got.Status.Helm.Revision).To(Equal(3)) + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: first}, &appsv1.ControllerRevision{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: got.Status.History[0].ControllerRevision}, &appsv1.ControllerRevision{})).To(Succeed()) + }) + + It("keeps a resolved version while the range allows it", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ">=0.8.0"))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(fetch(ns, "qwen").Status.Chart.Version).To(Equal(ver086)) + Expect(l.calls).To(Equal(1)) + + l.versions = []string{ver086, "0.9.0"} + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(fetch(ns, "qwen").Status.Chart.Version).To(Equal(ver086)) + Expect(l.calls).To(Equal(1)) + Expect(h.upgrades).To(Equal(0)) + + got := fetch(ns, "qwen") + got.Spec.Chart.Version = ">=0.9.0" + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(fetch(ns, "qwen").Status.Chart.Version).To(Equal("0.9.0")) + Expect(l.calls).To(Equal(2)) + Expect(h.upgrades).To(Equal(1)) + }) + + It("adopts a matching release and refuses drift", func() { + ns := takeNS() + h := &fakeHelm{} + h.put(ns, "qwen", &helm.Release{ + Revision: 7, Status: deployed, ChartName: "sglang", ChartVersion: ver086, + Config: map[string]any{"replicaCount": float64(1)}, + }) + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ">=0.8.0"))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(0)) + Expect(h.upgrades).To(Equal(0)) + got := fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseApplied)) + Expect(condition(got, servingv1alpha1.ConditionAdopted).Status).To(Equal(metav1.ConditionTrue)) + Expect(condition(got, servingv1alpha1.ConditionAdopted).Reason).To(Equal(servingv1alpha1.ReasonAdopted)) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonAdopted)) + Expect(got.Status.History[0].Revision).To(Equal(7)) + + ns2 := takeNS() + h.put(ns2, "qwen", &helm.Release{ + Revision: 3, Status: deployed, ChartName: "other", ChartVersion: "0.1.0", + Config: map[string]any{"replicaCount": float64(9)}, + }) + Expect(k8sClient.Create(ctx, newService(ns2, "qwen", ">=0.8.0"))).To(Succeed()) + _, err = doReconcile(r, ns2, "qwen") + Expect(err).NotTo(HaveOccurred()) + _, err = doReconcile(r, ns2, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(0)) + Expect(h.upgrades).To(Equal(0)) + drifted := fetch(ns2, "qwen") + Expect(drifted.Status.Phase).To(Equal(servingv1alpha1.PhaseFailed)) + Expect(drifted.Status.ObservedGeneration).To(Equal(drifted.Generation)) + Expect(condition(drifted, servingv1alpha1.ConditionAdopted).Status).To(Equal(metav1.ConditionFalse)) + Expect(condition(drifted, servingv1alpha1.ConditionAdopted).Reason).To(Equal(servingv1alpha1.ReasonDrift)) + Expect(drifted.Status.Message).To(ContainSubstring("chart")) + Expect(drifted.Status.Message).To(ContainSubstring("values")) + }) + + It("orphans a delete and uninstalls otherwise", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "keep", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "keep") + Expect(err).NotTo(HaveOccurred()) + got := fetch(ns, "keep") + got.Annotations[servingv1alpha1.AnnotationDeletionPolicy] = servingv1alpha1.DeletionPolicyOrphan + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + _, err = doReconcile(r, ns, "keep") + Expect(err).NotTo(HaveOccurred()) + Expect(h.uninstalls).To(Equal(0)) + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: "keep"}, &servingv1alpha1.LLMService{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + + Expect(k8sClient.Create(ctx, newService(ns, "drop", ver086))).To(Succeed()) + _, err = doReconcile(r, ns, "drop") + Expect(err).NotTo(HaveOccurred()) + drop := fetch(ns, "drop") + Expect(k8sClient.Delete(ctx, drop)).To(Succeed()) + _, err = doReconcile(r, ns, "drop") + Expect(err).NotTo(HaveOccurred()) + Expect(h.uninstalls).To(Equal(1)) + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: "drop"}, &servingv1alpha1.LLMService{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("leaves status unchanged while suspended", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + before := fetch(ns, "qwen") + before.Spec.Suspend = true + before.Spec.Layers = []servingv1alpha1.Layer{layer(`{"replicaCount":9}`)} + Expect(k8sClient.Update(ctx, before)).To(Succeed()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(1)) + Expect(h.upgrades).To(Equal(0)) + got := fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseApplied)) + Expect(got.Status.AppliedHash).To(Equal(before.Status.AppliedHash)) + Expect(got.Status.Helm.Revision).To(Equal(1)) + Expect(got.Status.ObservedGeneration).To(Equal(before.Status.ObservedGeneration)) + }) + + It("honors force-conflicts only for the current generation", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + obj := newService(ns, "qwen", ver086) + obj.Annotations[servingv1alpha1.AnnotationForceConflicts] = "1" + Expect(k8sClient.Create(ctx, obj)).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.lastForce).To(BeTrue()) + Expect(fetch(ns, "qwen").Status.History[0].ForceConflicts).To(BeTrue()) + + got := fetch(ns, "qwen") + got.Spec.Layers = []servingv1alpha1.Layer{layer(`{"replicaCount":2}`)} + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + Expect(fetch(ns, "qwen").Generation).To(Equal(int64(2))) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.lastForce).To(BeFalse()) + Expect(fetch(ns, "qwen").Status.History[0].ForceConflicts).To(BeFalse()) + }) + + It("records a helm failure", func() { + ns := takeNS() + h := &fakeHelm{installErr: errors.New("boom")} + l := &versionList{versions: []string{ver086}} + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(newReconciler(h, l, 10), ns, "qwen") + Expect(err).To(MatchError(ContainSubstring("boom"))) + got := fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseFailed)) + Expect(got.Status.Message).To(ContainSubstring("boom")) + Expect(got.Status.ObservedGeneration).To(Equal(got.Generation)) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonApplyFailed)) + Expect(condition(got, servingv1alpha1.ConditionApplied).Status).To(Equal(metav1.ConditionFalse)) + }) + + It("fails when the chart version cannot be resolved", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{err: errors.New("registry down")} + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ">=0.8.0"))).To(Succeed()) + _, err := doReconcile(newReconciler(h, l, 10), ns, "qwen") + Expect(err).To(HaveOccurred()) + got := fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseFailed)) + Expect(got.Status.Message).To(ContainSubstring("registry down")) + Expect(got.Status.ObservedGeneration).To(Equal(got.Generation)) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonChartResolveFailed)) + Expect(h.installs).To(Equal(0)) + }) +}) diff --git a/internal/controller/serving/predicate.go b/internal/controller/serving/predicate.go new file mode 100644 index 0000000..a723df2 --- /dev/null +++ b/internal/controller/serving/predicate.go @@ -0,0 +1,30 @@ +package serving + +import ( + "maps" + + "sigs.k8s.io/controller-runtime/pkg/event" +) + +// llmServicePredicate reconciles generation changes, annotation changes, and +// deletion. Status-only updates do not. +type llmServicePredicate struct{} + +func (llmServicePredicate) Create(event.CreateEvent) bool { return true } + +func (llmServicePredicate) Delete(event.DeleteEvent) bool { return true } + +func (llmServicePredicate) Generic(event.GenericEvent) bool { return true } + +func (llmServicePredicate) Update(e event.UpdateEvent) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return true + } + if !e.ObjectOld.GetDeletionTimestamp().IsZero() || !e.ObjectNew.GetDeletionTimestamp().IsZero() { + return true + } + if e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() { + return true + } + return !maps.Equal(e.ObjectOld.GetAnnotations(), e.ObjectNew.GetAnnotations()) +} diff --git a/internal/controller/serving/predicate_test.go b/internal/controller/serving/predicate_test.go new file mode 100644 index 0000000..673ded5 --- /dev/null +++ b/internal/controller/serving/predicate_test.go @@ -0,0 +1,45 @@ +package serving + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" +) + +func TestLLMServicePredicate(t *testing.T) { + p := llmServicePredicate{} + base := &servingv1alpha1.LLMService{} + base.Generation = 1 + base.Annotations = map[string]string{"a": "1"} + + same := base.DeepCopy() + same.Status.Phase = servingv1alpha1.PhaseApplied + if p.Update(event.UpdateEvent{ObjectOld: base, ObjectNew: same}) { + t.Fatal("status-only update reconciled") + } + + gen := base.DeepCopy() + gen.Generation = 2 + if !p.Update(event.UpdateEvent{ObjectOld: base, ObjectNew: gen}) { + t.Fatal("generation change ignored") + } + + ann := base.DeepCopy() + ann.Annotations = map[string]string{"a": "2"} + if !p.Update(event.UpdateEvent{ObjectOld: base, ObjectNew: ann}) { + t.Fatal("annotation change ignored") + } + + now := metav1.Now() + del := base.DeepCopy() + del.DeletionTimestamp = &now + if !p.Update(event.UpdateEvent{ObjectOld: base, ObjectNew: del}) { + t.Fatal("deletion ignored") + } + if !p.Create(event.CreateEvent{Object: base}) || !p.Delete(event.DeleteEvent{Object: base}) { + t.Fatal("create or delete ignored") + } +} diff --git a/internal/controller/serving/suite_test.go b/internal/controller/serving/suite_test.go new file mode 100644 index 0000000..4a418a4 --- /dev/null +++ b/internal/controller/serving/suite_test.go @@ -0,0 +1,118 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serving + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = servingv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + Eventually(func() error { + return testEnv.Stop() + }, time.Minute, time.Second).Should(Succeed()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/controller/serving/values.go b/internal/controller/serving/values.go new file mode 100644 index 0000000..82e2a9d --- /dev/null +++ b/internal/controller/serving/values.go @@ -0,0 +1,119 @@ +package serving + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" +) + +// mergeLayers merges helm values in list order. Maps merge recursively, every +// other value replaces, and a null deletes the key. +func mergeLayers(layers []servingv1alpha1.Layer) (map[string]any, error) { + dst := map[string]any{} + for _, layer := range layers { + if layer.Values == nil || len(layer.Values.Raw) == 0 || string(layer.Values.Raw) == "null" { + continue + } + var src map[string]any + if err := json.Unmarshal(layer.Values.Raw, &src); err != nil { + return nil, fmt.Errorf("layer %q: %w", layer.Name, err) + } + if src == nil { + continue + } + dst = mergeMaps(dst, src) + } + return dst, nil +} + +func mergeMaps(dst, src map[string]any) map[string]any { + if dst == nil { + dst = map[string]any{} + } + for k, v := range src { + if v == nil { + delete(dst, k) + continue + } + srcMap, srcIsMap := v.(map[string]any) + dstMap, dstIsMap := dst[k].(map[string]any) + if srcIsMap && dstIsMap { + dst[k] = mergeMaps(dstMap, srcMap) + continue + } + dst[k] = cloneValue(v) + } + return dst +} + +func cloneValue(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = cloneValue(e) + } + return out + case []any: + out := make([]any, len(t)) + for i := range t { + out[i] = cloneValue(t[i]) + } + return out + default: + return v + } +} + +// appliedHash is sha256 over the resolved chart and the merged values. +// encoding/json sorts map keys, so the digest does not depend on insertion order. +func appliedHash(chartName, chartVersion string, values map[string]any) (string, error) { + if values == nil { + values = map[string]any{} + } + payload := map[string]any{ + "chart": map[string]any{ + "name": chartName, + "version": chartVersion, + }, + "values": values, + } + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// jsonEqual compares values after a JSON round trip, treating nil and an empty +// map as equal. Helm omits an empty user-values map. +func jsonEqual(a, b any) (bool, error) { + ab, err := canonicalJSON(a) + if err != nil { + return false, err + } + bb, err := canonicalJSON(b) + if err != nil { + return false, err + } + return string(ab) == string(bb), nil +} + +func canonicalJSON(v any) ([]byte, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + if out == nil { + out = map[string]any{} + } + return json.Marshal(out) +} diff --git a/internal/controller/serving/values_test.go b/internal/controller/serving/values_test.go new file mode 100644 index 0000000..82b7f64 --- /dev/null +++ b/internal/controller/serving/values_test.go @@ -0,0 +1,80 @@ +package serving + +import ( + "strings" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + servingv1alpha1 "github.com/modelsphere/llm-operator/api/serving/v1alpha1" +) + +func raw(s string) *apiextensionsv1.JSON { + return &apiextensionsv1.JSON{Raw: []byte(s)} +} + +func TestMergeLayers(t *testing.T) { + got, err := mergeLayers([]servingv1alpha1.Layer{ + {Name: "a", Values: raw(`{"x":1,"m":{"a":1,"b":2},"s":[1],"keep":true}`)}, + {Name: "b", Values: raw(`{"x":2,"m":{"b":null,"c":3},"s":[2,3],"n":null}`)}, + {Name: "empty"}, + {Name: "scalar", Values: raw(`{"m":"replaced"}`)}, + }) + if err != nil { + t.Fatal(err) + } + if got["x"] != float64(2) { + t.Errorf("x = %#v", got["x"]) + } + if _, ok := got["n"]; ok { + t.Errorf("null key n was kept: %#v", got["n"]) + } + if got["keep"] != true { + t.Errorf("keep = %#v", got["keep"]) + } + m, ok := got["m"].(string) + if !ok || m != "replaced" { + t.Errorf("map replaced by scalar = %#v", got["m"]) + } + s, ok := got["s"].([]any) + if !ok || len(s) != 2 || s[0] != float64(2) || s[1] != float64(3) { + t.Errorf("slice = %#v", got["s"]) + } + + nested, err := mergeLayers([]servingv1alpha1.Layer{ + {Name: "a", Values: raw(`{"m":{"a":1,"b":{"c":1}}}`)}, + {Name: "b", Values: raw(`{"m":{"b":{"d":2}}}`)}, + }) + if err != nil { + t.Fatal(err) + } + inner := nested["m"].(map[string]any)["b"].(map[string]any) + if inner["c"] != float64(1) || inner["d"] != float64(2) { + t.Errorf("nested merge = %#v", nested["m"]) + } +} + +func TestAppliedHashIsStableAndSensitive(t *testing.T) { + a, err := appliedHash("sglang", "0.8.6", map[string]any{"b": 1, "a": map[string]any{"d": 1, "c": 2}}) + if err != nil { + t.Fatal(err) + } + b, err := appliedHash("sglang", "0.8.6", map[string]any{"a": map[string]any{"c": 2, "d": 1}, "b": 1}) + if err != nil { + t.Fatal(err) + } + if a != b || !strings.HasPrefix(a, "sha256:") || len(a) != len("sha256:")+64 { + t.Fatalf("hash = %s and %s", a, b) + } + if same, err := jsonEqual(nil, map[string]any{}); err != nil || !same { + t.Fatalf("nil and empty map: %v %v", same, err) + } + changed, err := appliedHash("sglang", "0.8.7", map[string]any{"b": 1, "a": map[string]any{"d": 1, "c": 2}}) + if err != nil || changed == a { + t.Fatalf("version change did not move the hash: %s", changed) + } + changed, err = appliedHash("sglang", "0.8.6", map[string]any{"b": 2}) + if err != nil || changed == a { + t.Fatalf("values change did not move the hash: %s", changed) + } +} diff --git a/internal/helm/getter.go b/internal/helm/getter.go new file mode 100644 index 0000000..b9e57fb --- /dev/null +++ b/internal/helm/getter.go @@ -0,0 +1,56 @@ +package helm + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +// restGetter adapts a controller-runtime *rest.Config to the Helm SDK. +type restGetter struct { + cfg *rest.Config + namespace string +} + +func newRESTGetter(cfg *rest.Config, namespace string) *restGetter { + return &restGetter{cfg: cfg, namespace: namespace} +} + +func (g *restGetter) ToRESTConfig() (*rest.Config, error) { + if g.cfg == nil { + return nil, fmt.Errorf("rest config is nil") + } + return rest.CopyConfig(g.cfg), nil +} + +func (g *restGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + cfg, err := g.ToRESTConfig() + if err != nil { + return nil, err + } + dc, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return nil, err + } + return memory.NewMemCacheClient(dc), nil +} + +func (g *restGetter) ToRESTMapper() (meta.RESTMapper, error) { + dc, err := g.ToDiscoveryClient() + if err != nil { + return nil, err + } + return restmapper.NewDeferredDiscoveryRESTMapper(dc), nil +} + +func (g *restGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return clientcmd.NewDefaultClientConfig(api.Config{}, &clientcmd.ConfigOverrides{ + Context: api.Context{Namespace: g.namespace}, + }) +} diff --git a/internal/helm/helm.go b/internal/helm/helm.go new file mode 100644 index 0000000..138b73a --- /dev/null +++ b/internal/helm/helm.go @@ -0,0 +1,192 @@ +// Package helm installs LLMService releases through the Helm SDK. +// Release storage is the Secret driver, so helm list keeps working. +package helm + +import ( + "context" + "errors" + "fmt" + "time" + + "helm.sh/helm/v4/pkg/action" + chartv2 "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/kube" + "helm.sh/helm/v4/pkg/release" + releasev1 "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/storage/driver" + "k8s.io/client-go/rest" +) + +const applyTimeout = 5 * time.Minute + +// Helm is the release operations the reconciler uses. +type Helm interface { + // Get returns the latest release. It returns nil, nil when the release is absent. + Get(ctx context.Context, ns, releaseName string) (*Release, error) + Install(ctx context.Context, ns, releaseName string, ch ChartRef, values map[string]any, opts ApplyOpts) (*Release, error) + Upgrade(ctx context.Context, ns, releaseName string, ch ChartRef, values map[string]any, opts ApplyOpts) (*Release, error) + Uninstall(ctx context.Context, ns, releaseName string) error +} + +// Release is the part of a helm release the reconciler compares and records. +// Config is the user-supplied values only. +type Release struct { + Revision int + Status string + ChartName string + ChartVersion string + Config map[string]any +} + +// ChartRef selects one exact chart version in a repository. +type ChartRef struct { + Repo string + Name string + Version string +} + +// Credentials is a basic-auth pair from a credentialsRef Secret. +// The Secret's data keys are username and password. +type Credentials struct { + Username string + Password string +} + +// ApplyOpts controls one install or upgrade. +type ApplyOpts struct { + ForceConflicts bool + Credentials *Credentials +} + +// ChartLoader supplies a chart without pulling it. Tests set it. +type ChartLoader func(ctx context.Context, ref ChartRef, creds *Credentials) (*chartv2.Chart, error) + +// Client is the Helm SDK implementation of Helm. +type Client struct { + restConfig *rest.Config + cacheDir string + load ChartLoader + newConfig func(namespace string) (*action.Configuration, error) +} + +// New builds a client that stores releases as Secrets and caches charts under cacheDir. +// An empty cacheDir uses os.TempDir()/llm-operator-charts. +func New(cfg *rest.Config, cacheDir string) *Client { + if cacheDir == "" { + cacheDir = defaultCacheDir() + } + return &Client{restConfig: cfg, cacheDir: cacheDir} +} + +func (c *Client) config(namespace string) (*action.Configuration, error) { + if c.newConfig != nil { + return c.newConfig(namespace) + } + cfg := action.NewConfiguration() + if err := cfg.Init(newRESTGetter(c.restConfig, namespace), namespace, "secret"); err != nil { + return nil, fmt.Errorf("helm configuration: %w", err) + } + return cfg, nil +} + +func (c *Client) Get(ctx context.Context, ns, releaseName string) (*Release, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + cfg, err := c.config(ns) + if err != nil { + return nil, err + } + rel, err := action.NewGet(cfg).Run(releaseName) + if err != nil { + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil, nil + } + return nil, err + } + return toRelease(rel) +} + +func (c *Client) Install(ctx context.Context, ns, releaseName string, ch ChartRef, values map[string]any, opts ApplyOpts) (*Release, error) { + cfg, err := c.config(ns) + if err != nil { + return nil, err + } + chart, err := c.openChart(ctx, ch, opts.Credentials) + if err != nil { + return nil, err + } + inst := action.NewInstall(cfg) + inst.ReleaseName = releaseName + inst.Namespace = ns + inst.ServerSideApply = true + inst.ForceConflicts = opts.ForceConflicts + inst.WaitStrategy = kube.HookOnlyStrategy + inst.Timeout = applyTimeout + inst.CreateNamespace = false + rel, err := inst.RunWithContext(ctx, chart, values) + if err != nil { + return nil, err + } + return toRelease(rel) +} + +func (c *Client) Upgrade(ctx context.Context, ns, releaseName string, ch ChartRef, values map[string]any, opts ApplyOpts) (*Release, error) { + cfg, err := c.config(ns) + if err != nil { + return nil, err + } + chart, err := c.openChart(ctx, ch, opts.Credentials) + if err != nil { + return nil, err + } + up := action.NewUpgrade(cfg) + up.Namespace = ns + up.ServerSideApply = "true" + up.ForceConflicts = opts.ForceConflicts + up.WaitStrategy = kube.HookOnlyStrategy + up.Timeout = applyTimeout + rel, err := up.RunWithContext(ctx, releaseName, chart, values) + if err != nil { + return nil, err + } + return toRelease(rel) +} + +func (c *Client) Uninstall(ctx context.Context, ns, releaseName string) error { + if err := ctx.Err(); err != nil { + return err + } + cfg, err := c.config(ns) + if err != nil { + return err + } + un := action.NewUninstall(cfg) + un.IgnoreNotFound = true + un.WaitStrategy = kube.HookOnlyStrategy + un.Timeout = applyTimeout + _, err = un.Run(releaseName) + if err != nil && errors.Is(err, driver.ErrReleaseNotFound) { + return nil + } + return err +} + +func toRelease(rel release.Releaser) (*Release, error) { + v1rel, ok := rel.(*releasev1.Release) + if !ok || v1rel == nil { + return nil, fmt.Errorf("unexpected helm release type %T", rel) + } + out := &Release{ + Revision: v1rel.Version, + Config: v1rel.Config, + } + if v1rel.Info != nil { + out.Status = string(v1rel.Info.Status) + } + if v1rel.Chart != nil && v1rel.Chart.Metadata != nil { + out.ChartName = v1rel.Chart.Metadata.Name + out.ChartVersion = v1rel.Chart.Metadata.Version + } + return out, nil +} diff --git a/internal/helm/helm_test.go b/internal/helm/helm_test.go new file mode 100644 index 0000000..393aba0 --- /dev/null +++ b/internal/helm/helm_test.go @@ -0,0 +1,103 @@ +package helm + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v4/pkg/action" + chartcommon "helm.sh/helm/v4/pkg/chart/common" + chartv2 "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/loader" + kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/storage" + "helm.sh/helm/v4/pkg/storage/driver" +) + +func testClient(t *testing.T, chartDir string) *Client { + t.Helper() + mem := driver.NewMemory() + cfg := action.NewConfiguration() + cfg.Releases = storage.Init(mem) + cfg.KubeClient = &kubefake.FailingKubeClient{ + PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: io.Discard}, + } + cfg.Capabilities = chartcommon.DefaultCapabilities + return &Client{ + cacheDir: t.TempDir(), + load: func(context.Context, ChartRef, *Credentials) (*chartv2.Chart, error) { + return loader.Load(chartDir) + }, + newConfig: func(ns string) (*action.Configuration, error) { + mem.SetNamespace(ns) + return cfg, nil + }, + } +} + +func writeTinyChart(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "templates"), 0o755); err != nil { + t.Fatal(err) + } + chartYAML := "apiVersion: v2\nname: tiny\nversion: 0.1.0\n" + if err := os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte(chartYAML), 0o644); err != nil { + t.Fatal(err) + } + tpl := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Release.Name }}\ndata:\n v: {{ .Values.v | quote }}\n" + if err := os.WriteFile(filepath.Join(dir, "templates", "cm.yaml"), []byte(tpl), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestGetInstallUpgradeUninstall(t *testing.T) { + const valueOne = "one" + c := testClient(t, writeTinyChart(t)) + ctx := t.Context() + const ns, name = "default", "tiny" + ref := ChartRef{Repo: "oci://example.com/charts", Name: "tiny", Version: "0.1.0"} + + got, err := c.Get(ctx, ns, name) + if err != nil || got != nil { + t.Fatalf("absent get = %#v, %v", got, err) + } + + installed, err := c.Install(ctx, ns, name, ref, map[string]any{"v": valueOne}, ApplyOpts{ForceConflicts: true}) + if err != nil { + t.Fatalf("install: %v", err) + } + if installed.Revision != 1 || installed.Status != "deployed" || installed.ChartName != "tiny" || installed.ChartVersion != "0.1.0" { + t.Fatalf("install release = %#v", installed) + } + if installed.Config["v"] != valueOne { + t.Fatalf("install config = %#v", installed.Config) + } + + got, err = c.Get(ctx, ns, name) + if err != nil || got == nil || got.Revision != 1 || got.Config["v"] != valueOne { + t.Fatalf("get after install = %#v, %v", got, err) + } + + upgraded, err := c.Upgrade(ctx, ns, name, ref, map[string]any{"v": "two"}, ApplyOpts{}) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + if upgraded.Revision != 2 || upgraded.Config["v"] != "two" || upgraded.Status != "deployed" { + t.Fatalf("upgrade release = %#v", upgraded) + } + + if err := c.Uninstall(ctx, ns, name); err != nil { + t.Fatalf("uninstall: %v", err) + } + got, err = c.Get(ctx, ns, name) + if err != nil || got != nil { + t.Fatalf("get after uninstall = %#v, %v", got, err) + } + if err := c.Uninstall(ctx, ns, name); err != nil { + t.Fatalf("uninstall absent: %v", err) + } +} diff --git a/internal/helm/pull.go b/internal/helm/pull.go new file mode 100644 index 0000000..2aecab2 --- /dev/null +++ b/internal/helm/pull.go @@ -0,0 +1,174 @@ +package helm + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + chartv2 "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/loader" + "helm.sh/helm/v4/pkg/registry" +) + +func defaultCacheDir() string { + return filepath.Join(os.TempDir(), "llm-operator-charts") +} + +func (c *Client) openChart(ctx context.Context, ref ChartRef, creds *Credentials) (*chartv2.Chart, error) { + if c.load != nil { + return c.load(ctx, ref, creds) + } + path, err := c.ensureChart(ctx, ref, creds) + if err != nil { + return nil, err + } + ch, err := loader.Load(path) + if err != nil { + return nil, fmt.Errorf("load chart %s %s: %w", ref.Name, ref.Version, err) + } + return ch, nil +} + +func (c *Client) ensureChart(ctx context.Context, ref ChartRef, creds *Credentials) (string, error) { + dest := c.chartPath(ref) + if st, err := os.Stat(dest); err == nil && st.Size() > 0 { + return dest, nil + } + body, err := c.pull(ctx, ref, creds) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + tmp, err := os.CreateTemp(filepath.Dir(dest), "chart-*.tgz") + if err != nil { + return "", err + } + tmpName := tmp.Name() + _, werr := tmp.Write(body) + cerr := tmp.Close() + if werr != nil || cerr != nil { + _ = os.Remove(tmpName) + if werr != nil { + return "", werr + } + return "", cerr + } + if err := os.Rename(tmpName, dest); err != nil { + _ = os.Remove(tmpName) + return "", err + } + return dest, nil +} + +func (c *Client) chartPath(ref ChartRef) string { + return filepath.Join(c.cacheDir, sanitize(ref.Repo), sanitize(ref.Name), sanitize(ref.Version), "chart.tgz") +} + +func sanitize(s string) string { + return strings.NewReplacer("/", "_", ":", "_", "\\", "_", "?", "_").Replace(s) +} + +func (c *Client) pull(ctx context.Context, ref ChartRef, creds *Credentials) ([]byte, error) { + switch { + case strings.HasPrefix(ref.Repo, "oci://"): + return pullOCI(ref, creds) + case strings.HasPrefix(ref.Repo, "https://"): + return pullHTTPS(ctx, ref, creds) + default: + return nil, fmt.Errorf("chart repo %q must start with https:// or oci://", ref.Repo) + } +} + +func pullOCI(ref ChartRef, creds *Credentials) ([]byte, error) { + opts := []registry.ClientOption{registry.ClientOptWriter(io.Discard)} + if creds != nil && (creds.Username != "" || creds.Password != "") { + opts = append(opts, registry.ClientOptBasicAuth(creds.Username, creds.Password)) + } + rc, err := registry.NewClient(opts...) + if err != nil { + return nil, fmt.Errorf("oci registry client: %w", err) + } + base := strings.TrimSuffix(strings.TrimPrefix(ref.Repo, "oci://"), "/") + tag := strings.ReplaceAll(ref.Version, "+", "_") + pullRef := base + "/" + ref.Name + ":" + tag + res, err := rc.Pull(pullRef, registry.PullOptIgnoreMissingProv(true)) + if err != nil { + return nil, fmt.Errorf("pull %s: %w", pullRef, err) + } + if res == nil || res.Chart == nil || len(res.Chart.Data) == 0 { + return nil, fmt.Errorf("pull %s: chart layer is empty", pullRef) + } + return res.Chart.Data, nil +} + +func pullHTTPS(ctx context.Context, ref ChartRef, creds *Credentials) ([]byte, error) { + indexURL := strings.TrimSuffix(ref.Repo, "/") + "/index.yaml" + body, err := httpGet(ctx, indexURL, creds) + if err != nil { + return nil, fmt.Errorf("chart index %s: %w", indexURL, err) + } + var idx struct { + Entries map[string][]struct { + Version string `yaml:"version"` + URLs []string `yaml:"urls"` + } `yaml:"entries"` + } + if err := yaml.Unmarshal(body, &idx); err != nil { + return nil, fmt.Errorf("chart index %s: %w", indexURL, err) + } + var raw string + for _, e := range idx.Entries[ref.Name] { + if e.Version == ref.Version && len(e.URLs) > 0 { + raw = e.URLs[0] + break + } + } + if raw == "" { + return nil, fmt.Errorf("chart index %s has no %s %s", indexURL, ref.Name, ref.Version) + } + base, err := url.Parse(strings.TrimSuffix(ref.Repo, "/") + "/") + if err != nil { + return nil, err + } + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + chartURL := base.ResolveReference(u).String() + archive, err := httpGet(ctx, chartURL, creds) + if err != nil { + return nil, fmt.Errorf("download %s: %w", chartURL, err) + } + return archive, nil +} + +func httpGet(ctx context.Context, raw string, creds *Credentials) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return nil, err + } + if creds != nil && (creds.Username != "" || creds.Password != "") { + req.SetBasicAuth(creds.Username, creds.Password) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", raw, resp.Status) + } + return body, nil +} From 99df3ea904c7adf841ce8a417529ff5885fec15f Mon Sep 17 00:00:00 2001 From: huskcass Date: Fri, 9 Oct 2026 18:27:23 +0800 Subject: [PATCH 2/2] fix(serving): adopt only deployed releases; keep shared ControllerRevisions A release left failed by the operator's own install is upgraded rather than adopted; any other non-deployed release is drift. Re-applying an older spec reuses and refreshes its ControllerRevision, which GC keeps while any history entry names it. Written with Grok Build (grok-4.7, high). --- docs/llmservice.md | 6 +- .../serving/llmservice_controller.go | 30 ++- .../serving/llmservice_controller_test.go | 199 +++++++++++++++++- 3 files changed, 223 insertions(+), 12 deletions(-) diff --git a/docs/llmservice.md b/docs/llmservice.md index f58ac4c..faec074 100644 --- a/docs/llmservice.md +++ b/docs/llmservice.md @@ -23,12 +23,12 @@ 3. Add finalizer `serving.modelsphere.dev/finalizer`. 4. Resolve `spec.chart.version` with the same rule as swiss `chart.Resolve`: an exact version, else the pinned `status.chart.version` while the range allows it, else the newest version the repository lists. A resolve error sets `phase: Failed`, condition `Applied=False` reason `ChartResolveFailed`, and retries with backoff. 5. Merge layers and hash them with the resolved chart. The same hash as `status.appliedHash` sets `phase: Applied` and `observedGeneration` when those are stale, then stops. No new history entry. -6. If a release exists and this object has never applied (`appliedHash` empty and `history` empty), adopt it. The release chart name, chart version, and user-supplied values must equal the resolved chart and merged layers after a JSON round trip. Equal: record `appliedHash`, `Adopted=True` reason `Adopted`, `Applied=True` reason `Adopted`, a history entry, and a ControllerRevision. No Helm install or upgrade. Not equal: `phase: Failed`, `Adopted=False` reason `Drift`, message names chart and/or values. No upgrade, and no requeue; the next spec change tries again. -7. Otherwise set `phase: Applying`, then install or upgrade through the Helm SDK with server-side apply and no resource wait (`HookOnlyStrategy`, the same outcome as `wait: false`). `serving.modelsphere.dev/force-conflicts` equal to the current generation sets Helm `ForceConflicts` for that apply and records `forceConflicts` on the history entry. Any other value is ignored. +6. If a `deployed` release exists and this object has never applied (`appliedHash` empty and `history` empty), adopt it. The release chart name, chart version, and user-supplied values must equal the resolved chart and merged layers after a JSON round trip. Equal: record `appliedHash`, `Adopted=True` reason `Adopted`, `Applied=True` reason `Adopted`, a history entry, and a ControllerRevision. No Helm install or upgrade. Not equal: `phase: Failed`, `Adopted=False` reason `Drift`, message names chart and/or values. No upgrade, and no requeue; the next spec change tries again. A release that is not `deployed` is not adopted. If `Applied` is already `ApplyFailed`, the controller upgrades that release. Otherwise it sets `phase: Failed`, `Adopted=False` reason `Drift`, message `release is , not deployed`, and does not requeue. +7. Otherwise set `phase: Applying` and leave `observedGeneration` at the last completed generation, then install or upgrade through the Helm SDK with server-side apply and no resource wait (`HookOnlyStrategy`, the same outcome as `wait: false`). `serving.modelsphere.dev/force-conflicts` equal to the current generation sets Helm `ForceConflicts` for that apply and records `forceConflicts` on the history entry. Any other value is ignored. On Helm error: `phase: Failed`, `Applied=False` reason `ApplyFailed`, `message` is the error, `observedGeneration` is the generation, and the error is returned for backoff. -On success the controller writes a ControllerRevision named `-<8 hex chars of the hash>` in the same namespace. It is owned by the LLMService, labelled `serving.modelsphere.dev/llmservice=`, and its data is JSON `{spec, annotations}` where annotations are only `swiss.modelsphere.dev/*`. `status.history` is prepended and capped (default 10, flag `--llmservice-history-limit`). ControllerRevisions that fall out of history are deleted. Each history entry copies those swiss annotations, `forceConflicts`, and `swiss.modelsphere.dev/action` when set. +On success the controller writes a ControllerRevision named `-<8 hex chars of the hash>` in the same namespace. It is owned by the LLMService, labelled `serving.modelsphere.dev/llmservice=`, and its data is JSON `{spec, annotations}` where annotations are only `swiss.modelsphere.dev/*`. Applying that spec again reuses the name and updates the revision's data and helm revision. `status.history` is prepended and capped (default 10, flag `--llmservice-history-limit`). ControllerRevisions are deleted only when no remaining history entry names them. Each history entry copies those swiss annotations, `forceConflicts`, and `swiss.modelsphere.dev/action` when set. Events are emitted for Installed, Upgraded, Adopted, Drift, ApplyFailed, and Uninstalled. diff --git a/internal/controller/serving/llmservice_controller.go b/internal/controller/serving/llmservice_controller.go index a9eacf6..204c88c 100644 --- a/internal/controller/serving/llmservice_controller.go +++ b/internal/controller/serving/llmservice_controller.go @@ -49,6 +49,8 @@ const ( credentialUsernameKey = "username" credentialPasswordKey = "password" actionAnnotation = servingv1alpha1.SwissAnnotationPrefix + "action" + // statusDeployed is the helm release status adoption accepts. + statusDeployed = "deployed" ) // VersionLister lists chart versions for spec.chart. Tests set it. @@ -164,11 +166,29 @@ func (r *LLMServiceReconciler) reconcileApply(ctx context.Context, obj *servingv return r.fail(ctx, obj, servingv1alpha1.ConditionApplied, servingv1alpha1.ReasonApplyFailed, err, true) } if rel != nil && obj.Status.AppliedHash == "" && len(obj.Status.History) == 0 { - return r.adopt(ctx, obj, ver, values, hash, rel) + return r.reconcileUnowned(ctx, obj, ver, values, hash, creds, rel) } return r.apply(ctx, obj, ver, values, hash, creds, rel) } +// reconcileUnowned handles a release this object has never recorded. +// Only a deployed release can be adopted. A release left failed by our own +// install is upgraded. Anything else is drift and is not retried. +func (r *LLMServiceReconciler) reconcileUnowned(ctx context.Context, obj *servingv1alpha1.LLMService, ver string, values map[string]any, hash string, creds *helm.Credentials, rel *helm.Release) (ctrl.Result, error) { + if rel.Status != statusDeployed { + if ownApplyFailed(obj) { + return r.apply(ctx, obj, ver, values, hash, creds, rel) + } + return r.drift(ctx, obj, fmt.Sprintf("release is %s, not deployed", rel.Status)) + } + return r.adopt(ctx, obj, ver, values, hash, rel) +} + +func ownApplyFailed(obj *servingv1alpha1.LLMService) bool { + cond := meta.FindStatusCondition(obj.Status.Conditions, servingv1alpha1.ConditionApplied) + return cond != nil && cond.Reason == servingv1alpha1.ReasonApplyFailed +} + func (r *LLMServiceReconciler) adopt(ctx context.Context, obj *servingv1alpha1.LLMService, ver string, values map[string]any, hash string, rel *helm.Release) (ctrl.Result, error) { chartOK := rel.ChartName == obj.Spec.Chart.Name && rel.ChartVersion == ver valuesOK, err := jsonEqual(rel.Config, values) @@ -187,7 +207,11 @@ func (r *LLMServiceReconciler) adopt(ctx context.Context, obj *servingv1alpha1.L } return ctrl.Result{}, nil } - msg := driftMessage(obj.Spec.Chart.Name, ver, rel, !valuesOK) + return r.drift(ctx, obj, driftMessage(obj.Spec.Chart.Name, ver, rel, !valuesOK)) +} + +// drift records a terminal adoption failure and does not requeue. +func (r *LLMServiceReconciler) drift(ctx context.Context, obj *servingv1alpha1.LLMService, msg string) (ctrl.Result, error) { meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ Type: servingv1alpha1.ConditionApplied, Status: metav1.ConditionFalse, @@ -195,11 +219,11 @@ func (r *LLMServiceReconciler) adopt(ctx context.Context, obj *servingv1alpha1.L Message: msg, ObservedGeneration: obj.Generation, }) - // Drift does not requeue. The next spec change reconciles again. return r.fail(ctx, obj, servingv1alpha1.ConditionAdopted, servingv1alpha1.ReasonDrift, errors.New(msg), false) } func (r *LLMServiceReconciler) apply(ctx context.Context, obj *servingv1alpha1.LLMService, ver string, values map[string]any, hash string, creds *helm.Credentials, existing *helm.Release) (ctrl.Result, error) { + // Applying is in progress. Keep observedGeneration at the last completed generation. obj.Status.Phase = servingv1alpha1.PhaseApplying if err := r.updateStatus(ctx, obj); err != nil { return ctrl.Result{}, err diff --git a/internal/controller/serving/llmservice_controller_test.go b/internal/controller/serving/llmservice_controller_test.go index a3a428f..8a65615 100644 --- a/internal/controller/serving/llmservice_controller_test.go +++ b/internal/controller/serving/llmservice_controller_test.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -39,8 +40,10 @@ import ( ) const ( - ver086 = "0.8.6" - deployed = "deployed" + ver086 = "0.8.6" + deployed = "deployed" + chartName = "sglang" + replicasKey = "replicaCount" ) var nsSeq int @@ -69,7 +72,7 @@ func newService(ns, name, version string) *servingv1alpha1.LLMService { }, Spec: servingv1alpha1.LLMServiceSpec{ Chart: servingv1alpha1.ChartSpec{ - Name: "sglang", + Name: chartName, Repo: "oci://ghcr.io/modelsphere/charts", Version: version, }, @@ -83,7 +86,12 @@ type fakeHelm struct { gets, installs, upgrades int uninstalls int installErr error + upgradeErr error lastForce bool + // keepFailed stores a failed release when Install returns installErr. + keepFailed bool + // hold blocks Install and Upgrade until closed. entered is signaled first. + hold, entered chan struct{} } func (f *fakeHelm) slot(ns, name string) string { return ns + "/" + name } @@ -98,10 +106,26 @@ func (f *fakeHelm) Get(_ context.Context, ns, name string) (*helm.Release, error return &cp, nil } +func (f *fakeHelm) gate() { + if f.hold == nil { + return + } + if f.entered != nil { + f.entered <- struct{}{} + } + <-f.hold +} + func (f *fakeHelm) Install(_ context.Context, ns, name string, ch helm.ChartRef, values map[string]any, opts helm.ApplyOpts) (*helm.Release, error) { f.installs++ f.lastForce = opts.ForceConflicts + f.gate() if f.installErr != nil { + if f.keepFailed { + f.put(ns, name, &helm.Release{ + Revision: 1, Status: "failed", ChartName: ch.Name, ChartVersion: ch.Version, Config: values, + }) + } return nil, f.installErr } rel := &helm.Release{Revision: 1, Status: deployed, ChartName: ch.Name, ChartVersion: ch.Version, Config: values} @@ -113,6 +137,10 @@ func (f *fakeHelm) Install(_ context.Context, ns, name string, ch helm.ChartRef, func (f *fakeHelm) Upgrade(_ context.Context, ns, name string, ch helm.ChartRef, values map[string]any, opts helm.ApplyOpts) (*helm.Release, error) { f.upgrades++ f.lastForce = opts.ForceConflicts + f.gate() + if f.upgradeErr != nil { + return nil, f.upgradeErr + } if f.installErr != nil { return nil, f.installErr } @@ -297,8 +325,8 @@ var _ = Describe("LLMService controller", func() { ns := takeNS() h := &fakeHelm{} h.put(ns, "qwen", &helm.Release{ - Revision: 7, Status: deployed, ChartName: "sglang", ChartVersion: ver086, - Config: map[string]any{"replicaCount": float64(1)}, + Revision: 7, Status: deployed, ChartName: chartName, ChartVersion: ver086, + Config: map[string]any{replicasKey: float64(1)}, }) l := &versionList{versions: []string{ver086}} r := newReconciler(h, l, 10) @@ -317,7 +345,7 @@ var _ = Describe("LLMService controller", func() { ns2 := takeNS() h.put(ns2, "qwen", &helm.Release{ Revision: 3, Status: deployed, ChartName: "other", ChartVersion: "0.1.0", - Config: map[string]any{"replicaCount": float64(9)}, + Config: map[string]any{replicasKey: float64(9)}, }) Expect(k8sClient.Create(ctx, newService(ns2, "qwen", ">=0.8.0"))).To(Succeed()) _, err = doReconcile(r, ns2, "qwen") @@ -347,9 +375,11 @@ var _ = Describe("LLMService controller", func() { got.Annotations[servingv1alpha1.AnnotationDeletionPolicy] = servingv1alpha1.DeletionPolicyOrphan Expect(k8sClient.Update(ctx, got)).To(Succeed()) Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + gets := h.gets _, err = doReconcile(r, ns, "keep") Expect(err).NotTo(HaveOccurred()) Expect(h.uninstalls).To(Equal(0)) + Expect(h.gets).To(Equal(gets)) err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: "keep"}, &servingv1alpha1.LLMService{}) Expect(apierrors.IsNotFound(err)).To(BeTrue()) @@ -365,6 +395,24 @@ var _ = Describe("LLMService controller", func() { Expect(apierrors.IsNotFound(err)).To(BeTrue()) }) + It("uninstalls when the release is already absent", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + obj := newService(ns, "gone", ver086) + obj.Finalizers = []string{servingv1alpha1.Finalizer} + Expect(k8sClient.Create(ctx, obj)).To(Succeed()) + Expect(k8sClient.Delete(ctx, obj)).To(Succeed()) + _, err := doReconcile(newReconciler(h, l, 10), ns, "gone") + Expect(err).NotTo(HaveOccurred()) + Expect(h.gets).To(Equal(0)) + Expect(h.installs).To(Equal(0)) + Expect(h.upgrades).To(Equal(0)) + Expect(h.uninstalls).To(Equal(1)) + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: "gone"}, &servingv1alpha1.LLMService{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + It("leaves status unchanged while suspended", func() { ns := takeNS() h := &fakeHelm{} @@ -426,6 +474,145 @@ var _ = Describe("LLMService controller", func() { Expect(condition(got, servingv1alpha1.ConditionApplied).Status).To(Equal(metav1.ConditionFalse)) }) + It("upgrades a failed release from our own install", func() { + ns := takeNS() + h := &fakeHelm{installErr: errors.New("boom"), keepFailed: true} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).To(MatchError(ContainSubstring("boom"))) + got := fetch(ns, "qwen") + Expect(got.Status.AppliedHash).To(BeEmpty()) + Expect(got.Status.History).To(BeEmpty()) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonApplyFailed)) + + h.installErr = nil + h.upgradeErr = errors.New("has no deployed releases") + _, err = doReconcile(r, ns, "qwen") + Expect(err).To(MatchError(ContainSubstring("has no deployed releases"))) + Expect(h.installs).To(Equal(1)) + Expect(h.upgrades).To(Equal(1)) + got = fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseFailed)) + Expect(got.Status.Message).To(ContainSubstring("has no deployed releases")) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonApplyFailed)) + Expect(condition(got, servingv1alpha1.ConditionAdopted)).To(BeNil()) + + h.upgradeErr = nil + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(1)) + Expect(h.upgrades).To(Equal(2)) + got = fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseApplied)) + Expect(condition(got, servingv1alpha1.ConditionApplied).Reason).To(Equal(servingv1alpha1.ReasonUpgraded)) + }) + + It("does not adopt a release that is not deployed", func() { + ns := takeNS() + h := &fakeHelm{} + h.put(ns, "qwen", &helm.Release{ + Revision: 1, Status: "failed", ChartName: chartName, ChartVersion: ver086, + Config: map[string]any{replicasKey: float64(1)}, + }) + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + Expect(h.installs).To(Equal(0)) + Expect(h.upgrades).To(Equal(0)) + got := fetch(ns, "qwen") + Expect(got.Status.Phase).To(Equal(servingv1alpha1.PhaseFailed)) + Expect(got.Status.Message).To(Equal("release is failed, not deployed")) + Expect(got.Status.ObservedGeneration).To(Equal(got.Generation)) + Expect(condition(got, servingv1alpha1.ConditionAdopted).Status).To(Equal(metav1.ConditionFalse)) + Expect(condition(got, servingv1alpha1.ConditionAdopted).Reason).To(Equal(servingv1alpha1.ReasonDrift)) + }) + + It("keeps observedGeneration while install is in progress", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + prev := fetch(ns, "qwen").Status.ObservedGeneration + Expect(prev).NotTo(BeZero()) + + delete(h.releases, h.slot(ns, "qwen")) + got := fetch(ns, "qwen") + got.Spec.Layers = []servingv1alpha1.Layer{layer(`{"replicaCount":4}`)} + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + + h.hold = make(chan struct{}) + h.entered = make(chan struct{}, 1) + done := make(chan error, 1) + go func() { + _, recErr := doReconcile(r, ns, "qwen") + done <- recErr + }() + Eventually(h.entered).WithTimeout(5 * time.Second).Should(Receive()) + mid := fetch(ns, "qwen") + Expect(mid.Status.Phase).To(Equal(servingv1alpha1.PhaseApplying)) + Expect(mid.Status.ObservedGeneration).To(Equal(prev)) + close(h.hold) + Eventually(done).WithTimeout(5 * time.Second).Should(Receive(BeNil())) + Expect(h.installs).To(Equal(2)) + Expect(h.upgrades).To(Equal(0)) + doneObj := fetch(ns, "qwen") + Expect(doneObj.Status.Phase).To(Equal(servingv1alpha1.PhaseApplied)) + Expect(doneObj.Status.ObservedGeneration).To(Equal(doneObj.Generation)) + }) + + It("reuses a ControllerRevision when the same spec is applied again", func() { + ns := takeNS() + h := &fakeHelm{} + l := &versionList{versions: []string{ver086}} + r := newReconciler(h, l, 10) + Expect(k8sClient.Create(ctx, newService(ns, "qwen", ver086))).To(Succeed()) + _, err := doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + + got := fetch(ns, "qwen") + got.Spec.Layers = []servingv1alpha1.Layer{layer(`{"replicaCount":2}`)} + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + + got = fetch(ns, "qwen") + got.Spec.Layers = []servingv1alpha1.Layer{layer(`{"replicaCount":1}`)} + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + _, err = doReconcile(r, ns, "qwen") + Expect(err).NotTo(HaveOccurred()) + + got = fetch(ns, "qwen") + Expect(got.Status.History).To(HaveLen(3)) + Expect(got.Status.History[0].Hash).To(Equal(got.Status.History[2].Hash)) + Expect(got.Status.History[0].Hash).NotTo(Equal(got.Status.History[1].Hash)) + Expect(got.Status.History[0].ControllerRevision).To(Equal(got.Status.History[2].ControllerRevision)) + Expect(got.Status.History[0].ControllerRevision).NotTo(Equal(got.Status.History[1].ControllerRevision)) + Expect(got.Status.History[0].Revision).To(Equal(3)) + Expect(got.Status.History[1].Revision).To(Equal(2)) + Expect(got.Status.History[2].Revision).To(Equal(1)) + + var crA, crB appsv1.ControllerRevision + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: got.Status.History[0].ControllerRevision}, &crA)).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: got.Status.History[1].ControllerRevision}, &crB)).To(Succeed()) + Expect(crA.Revision).To(Equal(int64(got.Status.History[0].Revision))) + Expect(crB.Revision).To(Equal(int64(2))) + var snap revisionSnapshot + Expect(json.Unmarshal(crA.Data.Raw, &snap)).To(Succeed()) + Expect(snap.Spec.Layers).To(HaveLen(1)) + var values map[string]any + Expect(json.Unmarshal(snap.Spec.Layers[0].Values.Raw, &values)).To(Succeed()) + Expect(values[replicasKey]).To(Equal(float64(1))) + }) + It("fails when the chart version cannot be resolved", func() { ns := takeNS() h := &fakeHelm{}