diff --git a/CHANGELOG.md b/CHANGELOG.md index 937d207..4bad9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`LiteLLMInstance.spec.workload.managed: false`** ([#29](https://github.com/PalenaAI/litellm-operator/issues/29)) — attaches an instance to a LiteLLM proxy the operator did not deploy, so the entity CRDs (`LiteLLMTeam`, `LiteLLMVirtualKey`, `LiteLLMBudget`, `LiteLLMModel`, ...) can be used against a proxy owned by a Helm chart, a GitOps pipeline or an internal platform. Workload reconciliation and auto-rollback are skipped entirely: nothing is created, and no existing object is adopted or mutated, replacing the RBAC-denial workaround that left the instance permanently `Degraded` while it worked. `spec.database.migration` is ignored: an externally-managed proxy owns its own schema, and the migration Job would otherwise run `prisma migrate deploy` from `spec.image.tag` (defaulting to `latest`) against a database the operator does not own. Health probing, config sync, and finalizer-based cleanup of upstream entities are unaffected. +- **`LiteLLMInstance.spec.workload.endpoint`** — sets the admin API URL explicitly instead of deriving `http(s)://..svc:`, so an unmanaged instance no longer has to be named after a Service it does not own, and can attach to a proxy in another namespace or outside the cluster. Valid only when `managed` is `false`; rejected by a CEL rule otherwise. Readiness for an unmanaged instance now comes from the admin API answering at that endpoint rather than from a name-matched Deployment, which also makes a StatefulSet-backed or off-cluster proxy work; the `Ready` condition reports `ProxyReachable` / `ProxyNotReachable` and no `PodsHealthy` condition is set, because the operator owns no pods. `status.version` is left empty instead of echoing `spec.image.tag`, which describes nothing the operator deployed; it is filled in only when the proxy discloses `litellm_version` on `/health/readiness` (LiteLLM gates that behind its own `allow_public_health_readiness_details`). + ## [0.23.0] - 2026-08-30 ### Added diff --git a/README.md b/README.md index efb3bef..dc12e14 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,44 @@ spec: port: 4000 ``` +### Attaching to an Existing LiteLLM Deployment + +Already running LiteLLM from a Helm chart, a GitOps pipeline or an internal platform? Set `workload.managed: false` and the operator provisions **nothing** — no Deployment, Service, ConfigMap or ServiceAccount is created, and nothing existing is adopted or mutated. You get the entity CRDs (`LiteLLMTeam`, `LiteLLMVirtualKey`, `LiteLLMBudget`, `LiteLLMModel`, ...) against the proxy you already have. + +```yaml +apiVersion: litellm.palena.ai/v1alpha1 +kind: LiteLLMInstance +metadata: + name: my-gateway +spec: + workload: + managed: false + # Optional. Defaults to http(s)://..svc: + endpoint: http://litellm.platform.svc:4000 + masterKey: + secretRef: + name: litellm-master-key + key: LITELLM_MASTER_KEY + database: {} +``` + +Two fields matter: + +- **`endpoint`** — where the operator reaches the admin API. Omit it and the operator derives `http(s)://..svc:`, which requires this CR to be named after the existing Service. Set it explicitly to attach to a Service under a different name, in another namespace, or to a proxy outside the cluster entirely. +- **`masterKey`** — the admin key of the *existing* proxy. `autoGenerate: true` makes no sense here: the operator would mint a key the running proxy has never heard of. + +Readiness comes from the admin API answering (`/health/liveliness`), not from a Deployment the operator does not own, so a StatefulSet or an off-cluster proxy works the same way: + +```bash +kubectl get litellminstance my-gateway +# NAME READY ENDPOINT VERSION AGE +# my-gateway True http://litellm.platform.svc:4000 30s +``` + +`status.version` is left empty rather than echoing an image tag the operator never chose. It is populated only when the proxy discloses `litellm_version` on `/health/readiness`, which LiteLLM does only if its own `general_settings` sets `allow_public_health_readiness_details: true` — that endpoint takes no auth, so the master key does not unlock it. + +Everything else keeps working: health probing, config sync, and finalizer-based cleanup of upstream entities. Only workload provisioning and auto-rollback are skipped. `endpoint` is rejected when `managed` is true. + ### 5. Register a model ```yaml diff --git a/api/v1alpha1/litellminstance_types.go b/api/v1alpha1/litellminstance_types.go index b6dc093..68cf338 100644 --- a/api/v1alpha1/litellminstance_types.go +++ b/api/v1alpha1/litellminstance_types.go @@ -28,6 +28,13 @@ type LiteLLMInstanceSpec struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Image" Image ImageSpec `json:"image,omitempty"` + // Workload controls whether the operator provisions the proxy workload. + // Omit it (the default) to have the operator create and own the + // Deployment, Service, ConfigMap and ServiceAccount as usual. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Workload" + Workload *WorkloadSpec `json:"workload,omitempty"` + // Number of LiteLLM proxy replicas. // +kubebuilder:default=1 // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Replicas" @@ -613,6 +620,36 @@ type ImageSpec struct { PullSecrets []SecretRef `json:"pullSecrets,omitempty"` } +// WorkloadSpec controls whether the operator provisions the LiteLLM proxy +// workload, or merely attaches to one that already exists. +// +// With managed=false the operator creates nothing: no Deployment, Service, +// ConfigMap, ServiceAccount or optional resource is reconciled, and no +// existing object is adopted or mutated. The instance still resolves an +// endpoint and a master key, so the entity CRDs (LiteLLMTeam, +// LiteLLMVirtualKey, LiteLLMBudget, LiteLLMModel, ...) work against a proxy +// deployed by a Helm chart, a GitOps pipeline or anything else. +// +kubebuilder:validation:XValidation:rule="!has(self.endpoint) || (has(self.managed) && !self.managed)",message="workload.endpoint is only valid when workload.managed is false" +type WorkloadSpec struct { + // Managed indicates the operator owns the proxy workload. Set false to + // attach to a deployment managed elsewhere. Defaults to true, including + // when unset, so an omitted field never silently orphans a workload. + // +optional + // +kubebuilder:default=true + Managed *bool `json:"managed,omitempty"` + + // Endpoint is the base URL of the existing proxy, e.g. + // "http://litellm.platform.svc:4000". Only valid when managed is false. + // Defaults to the in-cluster Service address derived from the instance + // name, namespace and spec.service.port, which requires this CR to be + // named after the existing Service. Set it explicitly to attach to a + // Service under a different name, in another namespace, or to a proxy + // outside the cluster. + // +optional + // +kubebuilder:validation:Pattern=`^https?://[^\s/?#]+` + Endpoint string `json:"endpoint,omitempty"` +} + // AutoscalingSpec defines horizontal pod autoscaling settings. type AutoscalingSpec struct { // Enable autoscaling. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c0ec1b7..d645ac9 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1959,6 +1959,11 @@ func (in *LiteLLMInstanceList) DeepCopyObject() runtime.Object { func (in *LiteLLMInstanceSpec) DeepCopyInto(out *LiteLLMInstanceSpec) { *out = *in in.Image.DeepCopyInto(&out.Image) + if in.Workload != nil { + in, out := &in.Workload, &out.Workload + *out = new(WorkloadSpec) + (*in).DeepCopyInto(*out) + } if in.Autoscaling != nil { in, out := &in.Autoscaling, &out.Autoscaling *out = new(AutoscalingSpec) @@ -4539,3 +4544,23 @@ func (in *VaultConfig) DeepCopy() *VaultConfig { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkloadSpec) DeepCopyInto(out *WorkloadSpec) { + *out = *in + if in.Managed != nil { + in, out := &in.Managed, &out.Managed + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadSpec. +func (in *WorkloadSpec) DeepCopy() *WorkloadSpec { + if in == nil { + return nil + } + out := new(WorkloadSpec) + in.DeepCopyInto(out) + return out +} diff --git a/bundle/manifests/litellm-operator.clusterserviceversion.yaml b/bundle/manifests/litellm-operator.clusterserviceversion.yaml index 21f4a8e..95eaf81 100644 --- a/bundle/manifests/litellm-operator.clusterserviceversion.yaml +++ b/bundle/manifests/litellm-operator.clusterserviceversion.yaml @@ -545,7 +545,7 @@ metadata: capabilities: Deep Insights categories: AI/Machine Learning containerImage: ghcr.io/palenaai/litellm-operator:v0.23.0 - createdAt: "2026-08-30T14:56:20Z" + createdAt: "2026-09-05T16:13:51Z" description: Kubernetes operator for deploying and managing production-ready LiteLLM AI Gateway instances. operators.operatorframework.io/builder: operator-sdk-v1.42.1 @@ -903,6 +903,12 @@ spec: - description: Upgrade strategy configuration. displayName: Upgrade path: upgrade + - description: |- + Workload controls whether the operator provisions the proxy workload. + Omit it (the default) to have the operator create and own the + Deployment, Service, ConfigMap and ServiceAccount as usual. + displayName: Workload + path: workload statusDescriptors: - description: |- UnhealthyPods explains why proxy pods are not running — crash loops, image diff --git a/bundle/manifests/litellm.palena.ai_litellminstances.yaml b/bundle/manifests/litellm.palena.ai_litellminstances.yaml index 56a27a6..72e58e2 100644 --- a/bundle/manifests/litellm.palena.ai_litellminstances.yaml +++ b/bundle/manifests/litellm.palena.ai_litellminstances.yaml @@ -4578,6 +4578,35 @@ spec: - recreate type: string type: object + workload: + description: |- + Workload controls whether the operator provisions the proxy workload. + Omit it (the default) to have the operator create and own the + Deployment, Service, ConfigMap and ServiceAccount as usual. + properties: + endpoint: + description: |- + Endpoint is the base URL of the existing proxy, e.g. + "http://litellm.platform.svc:4000". Only valid when managed is false. + Defaults to the in-cluster Service address derived from the instance + name, namespace and spec.service.port, which requires this CR to be + named after the existing Service. Set it explicitly to attach to a + Service under a different name, in another namespace, or to a proxy + outside the cluster. + pattern: ^https?://[^\s/?#]+ + type: string + managed: + default: true + description: |- + Managed indicates the operator owns the proxy workload. Set false to + attach to a deployment managed elsewhere. Defaults to true, including + when unset, so an omitted field never silently orphans a workload. + type: boolean + type: object + x-kubernetes-validations: + - message: workload.endpoint is only valid when workload.managed is + false + rule: '!has(self.endpoint) || (has(self.managed) && !self.managed)' required: - database - masterKey diff --git a/config/crd/bases/litellm.palena.ai_litellminstances.yaml b/config/crd/bases/litellm.palena.ai_litellminstances.yaml index 507ddfc..11f8721 100644 --- a/config/crd/bases/litellm.palena.ai_litellminstances.yaml +++ b/config/crd/bases/litellm.palena.ai_litellminstances.yaml @@ -4578,6 +4578,35 @@ spec: - recreate type: string type: object + workload: + description: |- + Workload controls whether the operator provisions the proxy workload. + Omit it (the default) to have the operator create and own the + Deployment, Service, ConfigMap and ServiceAccount as usual. + properties: + endpoint: + description: |- + Endpoint is the base URL of the existing proxy, e.g. + "http://litellm.platform.svc:4000". Only valid when managed is false. + Defaults to the in-cluster Service address derived from the instance + name, namespace and spec.service.port, which requires this CR to be + named after the existing Service. Set it explicitly to attach to a + Service under a different name, in another namespace, or to a proxy + outside the cluster. + pattern: ^https?://[^\s/?#]+ + type: string + managed: + default: true + description: |- + Managed indicates the operator owns the proxy workload. Set false to + attach to a deployment managed elsewhere. Defaults to true, including + when unset, so an omitted field never silently orphans a workload. + type: boolean + type: object + x-kubernetes-validations: + - message: workload.endpoint is only valid when workload.managed is + false + rule: '!has(self.endpoint) || (has(self.managed) && !self.managed)' required: - database - masterKey diff --git a/config/manifests/bases/litellm-operator.clusterserviceversion.yaml b/config/manifests/bases/litellm-operator.clusterserviceversion.yaml index 0640182..a332888 100644 --- a/config/manifests/bases/litellm-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/litellm-operator.clusterserviceversion.yaml @@ -390,6 +390,12 @@ spec: - description: Upgrade strategy configuration. displayName: Upgrade path: upgrade + - description: |- + Workload controls whether the operator provisions the proxy workload. + Omit it (the default) to have the operator create and own the + Deployment, Service, ConfigMap and ServiceAccount as usual. + displayName: Workload + path: workload statusDescriptors: - description: |- UnhealthyPods explains why proxy pods are not running — crash loops, image diff --git a/config/samples/litellm_v1alpha1_litellminstance_unmanaged.yaml b/config/samples/litellm_v1alpha1_litellminstance_unmanaged.yaml new file mode 100644 index 0000000..5f3afef --- /dev/null +++ b/config/samples/litellm_v1alpha1_litellminstance_unmanaged.yaml @@ -0,0 +1,31 @@ +# Attaching to a LiteLLM proxy the operator did not deploy. +# +# With workload.managed=false the operator creates nothing — no Deployment, +# Service, ConfigMap or ServiceAccount — and adopts nothing. It only resolves +# an endpoint and a master key, which is all the entity CRDs (LiteLLMTeam, +# LiteLLMVirtualKey, LiteLLMBudget, LiteLLMModel, ...) need. Use it when the +# proxy is owned by a Helm chart, a GitOps pipeline or an internal platform. +apiVersion: litellm.palena.ai/v1alpha1 +kind: LiteLLMInstance +metadata: + labels: + app.kubernetes.io/name: litellm-operator + app.kubernetes.io/managed-by: kustomize + name: litellminstance-unmanaged-sample +spec: + workload: + managed: false + # Optional. Defaults to http(s)://..svc:, + # which requires this CR to be named after the existing Service. Set it to + # attach to a Service under another name or namespace, or to a proxy + # outside the cluster. + endpoint: http://litellm.platform.svc:4000 + + # The operator needs the admin key of the existing proxy to manage entities. + masterKey: + secretRef: + name: litellm-master-key + key: LITELLM_MASTER_KEY + + # The existing proxy owns its own database; nothing to configure here. + database: {} diff --git a/deploy/charts/litellm-operator/crds/litellm.palena.ai_litellminstances.yaml b/deploy/charts/litellm-operator/crds/litellm.palena.ai_litellminstances.yaml index 507ddfc..11f8721 100644 --- a/deploy/charts/litellm-operator/crds/litellm.palena.ai_litellminstances.yaml +++ b/deploy/charts/litellm-operator/crds/litellm.palena.ai_litellminstances.yaml @@ -4578,6 +4578,35 @@ spec: - recreate type: string type: object + workload: + description: |- + Workload controls whether the operator provisions the proxy workload. + Omit it (the default) to have the operator create and own the + Deployment, Service, ConfigMap and ServiceAccount as usual. + properties: + endpoint: + description: |- + Endpoint is the base URL of the existing proxy, e.g. + "http://litellm.platform.svc:4000". Only valid when managed is false. + Defaults to the in-cluster Service address derived from the instance + name, namespace and spec.service.port, which requires this CR to be + named after the existing Service. Set it explicitly to attach to a + Service under a different name, in another namespace, or to a proxy + outside the cluster. + pattern: ^https?://[^\s/?#]+ + type: string + managed: + default: true + description: |- + Managed indicates the operator owns the proxy workload. Set false to + attach to a deployment managed elsewhere. Defaults to true, including + when unset, so an omitted field never silently orphans a workload. + type: boolean + type: object + x-kubernetes-validations: + - message: workload.endpoint is only valid when workload.managed is + false + rule: '!has(self.endpoint) || (has(self.managed) && !self.managed)' required: - database - masterKey diff --git a/docs/changelog.md b/docs/changelog.md index 937d207..4bad9a7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`LiteLLMInstance.spec.workload.managed: false`** ([#29](https://github.com/PalenaAI/litellm-operator/issues/29)) — attaches an instance to a LiteLLM proxy the operator did not deploy, so the entity CRDs (`LiteLLMTeam`, `LiteLLMVirtualKey`, `LiteLLMBudget`, `LiteLLMModel`, ...) can be used against a proxy owned by a Helm chart, a GitOps pipeline or an internal platform. Workload reconciliation and auto-rollback are skipped entirely: nothing is created, and no existing object is adopted or mutated, replacing the RBAC-denial workaround that left the instance permanently `Degraded` while it worked. `spec.database.migration` is ignored: an externally-managed proxy owns its own schema, and the migration Job would otherwise run `prisma migrate deploy` from `spec.image.tag` (defaulting to `latest`) against a database the operator does not own. Health probing, config sync, and finalizer-based cleanup of upstream entities are unaffected. +- **`LiteLLMInstance.spec.workload.endpoint`** — sets the admin API URL explicitly instead of deriving `http(s)://..svc:`, so an unmanaged instance no longer has to be named after a Service it does not own, and can attach to a proxy in another namespace or outside the cluster. Valid only when `managed` is `false`; rejected by a CEL rule otherwise. Readiness for an unmanaged instance now comes from the admin API answering at that endpoint rather than from a name-matched Deployment, which also makes a StatefulSet-backed or off-cluster proxy work; the `Ready` condition reports `ProxyReachable` / `ProxyNotReachable` and no `PodsHealthy` condition is set, because the operator owns no pods. `status.version` is left empty instead of echoing `spec.image.tag`, which describes nothing the operator deployed; it is filled in only when the proxy discloses `litellm_version` on `/health/readiness` (LiteLLM gates that behind its own `allow_public_health_readiness_details`). + ## [0.23.0] - 2026-08-30 ### Added diff --git a/docs/reference/litellminstance.md b/docs/reference/litellminstance.md index 01a6049..044742a 100644 --- a/docs/reference/litellminstance.md +++ b/docs/reference/litellminstance.md @@ -287,6 +287,51 @@ spec: | `pullPolicy` | string | `IfNotPresent` | Image pull policy | | `pullSecrets` | []SecretRef | — | Image pull secrets | +### `workload` + +Controls whether the operator provisions the proxy workload. Omit the block (the default) and the operator creates and owns the Deployment, Service, ConfigMap and ServiceAccount as it always has. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `managed` | bool | `true` | Whether the operator owns the proxy workload. Set `false` to attach to a deployment managed elsewhere | +| `endpoint` | string | — | Base URL of the existing proxy, e.g. `http://litellm.platform.svc:4000`. Only valid when `managed` is `false`. Defaults to `http(s)://..svc:` | + +With `managed: false` the operator creates nothing and adopts nothing — no existing object is mutated, no owner reference added. It resolves an endpoint and a master key so the entity CRDs (`LiteLLMTeam`, `LiteLLMVirtualKey`, `LiteLLMBudget`, `LiteLLMModel`, ...) work against a proxy owned by a Helm chart, a GitOps pipeline or an internal platform. + +```yaml +spec: + workload: + managed: false + endpoint: http://litellm.platform.svc:4000 + masterKey: + secretRef: + name: litellm-master-key + key: LITELLM_MASTER_KEY + database: {} +``` + +Behaviour differences when unmanaged: + +| | Managed (default) | Unmanaged | +| --- | --- | --- | +| Deployment, Service, ConfigMap, ServiceAccount | Created and reconciled | Never touched | +| Ingress, Route, HTTPRoute, HPA, PDB, NetworkPolicy, ServiceMonitor | Created when enabled | Never touched | +| Auto-rollback (`spec.upgrade.autoRollback`) | Active | Skipped | +| Database migration Job (`spec.database.migration`) | Created when enabled | Never — the proxy owns its schema | +| `status.ready` | At least one Deployment replica ready | Admin API answers `/health/liveliness` at `status.endpoint` | +| `status.replicas` / `readyReplicas` | Deployment counts | `0` | +| `status.version` | `spec.image.tag` (or `latest`) | Empty, unless the proxy discloses `litellm_version` on `/health/readiness` | +| `PodsHealthy` condition | Set | Absent — the operator owns no pods | +| `DatabaseReady` condition reason | `MigrationSkipped` / `MigrationComplete` / … | `WorkloadUnmanaged` | +| `Ready` condition reason | `AllResourcesReady` / `DeploymentNotReady` | `ProxyReachable` / `ProxyNotReachable` | +| Health probing, config sync, entity CRDs, finalizer cleanup | Active | Active | + +`masterKey.autoGenerate` is not useful here: the operator would mint a key the running proxy has never seen. Reference the existing proxy's admin key with `masterKey.secretRef`. + +Database fields describe the proxy's own database and are only consumed when building the workload, so `database: {}` is the normal unmanaged value. + +`spec.database.migration` is ignored entirely. An externally-managed proxy owns its own schema: LiteLLM migrates on startup, and whatever deployed it has its own migration hook, so a second migrator would race the real one. The migration Job also takes its image from `spec.image.tag` — meaningless for a proxy the operator did not deploy, and defaulting to `latest` — which would run `prisma migrate deploy` at an arbitrary schema version against a database the operator does not own. `DatabaseReady` reports `WorkloadUnmanaged`, and says so explicitly if you configured a migration anyway. + ### `replicas` | Field | Type | Default | Description | @@ -835,11 +880,11 @@ Admin UI configuration. Controls UI availability, access restrictions, model per | Field | Type | Description | | --- | --- | --- | -| `ready` | bool | Whether the instance is fully ready | -| `replicas` | int32 | Current replica count | -| `readyReplicas` | int32 | Ready replica count | -| `endpoint` | string | Internal cluster endpoint URL | -| `version` | string | Current LiteLLM version | +| `ready` | bool | Whether the instance is fully ready. Managed: at least one Deployment replica is serving. Unmanaged: the admin API answers at `endpoint` | +| `replicas` | int32 | Current replica count (`0` when `workload.managed` is `false`) | +| `readyReplicas` | int32 | Ready replica count (`0` when `workload.managed` is `false`) | +| `endpoint` | string | Endpoint URL the operator uses to reach the admin API: `spec.workload.endpoint` when set, otherwise the derived in-cluster Service address | +| `version` | string | Current LiteLLM version. Managed: `spec.image.tag`. Unmanaged: the `litellm_version` the proxy reports on `/health/readiness`, or empty — LiteLLM includes that field only when its `general_settings` sets `allow_public_health_readiness_details: true`, and the endpoint is unauthenticated so the master key does not unlock it | | `database` | DatabaseStatus | Database connection status | | `redis` | *RedisStatus | Redis connection status | | `configSync` | *ConfigSyncStatus | Config sync status and counts | diff --git a/internal/controller/common.go b/internal/controller/common.go index 6992f21..02dd90d 100644 --- a/internal/controller/common.go +++ b/internal/controller/common.go @@ -160,14 +160,7 @@ func resolveInstance( return nil, fmt.Errorf("instance %q is not ready", ref.Name) } - masterKeyRef := instance.Spec.MasterKey.SecretRef - if masterKeyRef == nil && instance.Spec.MasterKey.AutoGenerate { - masterKeyRef = &litellmv1alpha1.SecretKeyRef{ - Name: instance.Name + "-master-key", - Key: "master-key", - } - } - masterKey, err := getSecretValue(ctx, c, namespace, masterKeyRef) + masterKey, err := getSecretValue(ctx, c, namespace, masterKeyRef(&instance)) if err != nil { return nil, fmt.Errorf("get master key: %w", err) } @@ -180,6 +173,49 @@ func resolveInstance( }, nil } +// masterKeyRef returns the Secret reference holding the admin master key, +// falling back to the Secret the operator generates when autoGenerate is set. +// Returns nil when neither is configured. +func masterKeyRef(instance *litellmv1alpha1.LiteLLMInstance) *litellmv1alpha1.SecretKeyRef { + if ref := instance.Spec.MasterKey.SecretRef; ref != nil { + return ref + } + if instance.Spec.MasterKey.AutoGenerate { + return &litellmv1alpha1.SecretKeyRef{ + Name: instance.Name + "-master-key", + Key: "master-key", + } + } + return nil +} + +// workloadManaged reports whether the operator provisions the proxy workload. +// Absent spec.workload means managed, so instances written before the field +// existed keep their behaviour. +func workloadManaged(instance *litellmv1alpha1.LiteLLMInstance) bool { + w := instance.Spec.Workload + return w == nil || w.Managed == nil || *w.Managed +} + +// instanceEndpoint returns the base URL every controller and health probe uses +// to reach the admin API: the explicit spec.workload.endpoint when attaching to +// an externally-managed proxy, otherwise the in-cluster Service address derived +// from the instance name. +func instanceEndpoint(instance *litellmv1alpha1.LiteLLMInstance) string { + if w := instance.Spec.Workload; w != nil && w.Endpoint != "" { + return w.Endpoint + } + port := instance.Spec.Service.Port + if port == 0 { + port = 4000 + } + scheme := "http" + if instanceServesTLS(instance) { + scheme = "https" + } + return fmt.Sprintf("%s://%s.%s.svc:%d", scheme, instance.Name, instance.Namespace, port) +} + // instanceServesTLS reports whether the proxy is configured to serve HTTPS. func instanceServesTLS(instance *litellmv1alpha1.LiteLLMInstance) bool { return instance.Spec.TLS != nil && instance.Spec.TLS.ServerCertSecretRef != nil diff --git a/internal/controller/litellminstance_controller.go b/internal/controller/litellminstance_controller.go index 64df3d4..b1abf71 100644 --- a/internal/controller/litellminstance_controller.go +++ b/internal/controller/litellminstance_controller.go @@ -113,11 +113,17 @@ func (r *LiteLLMInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Reconcile database migration status r.reconcileMigrationStatus(ctx, &instance, labels) - // Reconcile all managed resources - reconcileErr := r.reconcileResources(ctx, &instance, labels, licenseSecretName, guardrails) + // Reconcile all managed resources. With spec.workload.managed=false the + // proxy belongs to something else (a Helm chart, a GitOps pipeline), so + // the operator creates nothing and adopts nothing — it only resolves the + // endpoint and master key the entity CRDs need. + var reconcileErr error + if workloadManaged(&instance) { + reconcileErr = r.reconcileResources(ctx, &instance, labels, licenseSecretName, guardrails) - // Auto-rollback: track successful deployment revision and rollback on failure - r.reconcileAutoRollback(ctx, &instance) + // Auto-rollback: track successful deployment revision and rollback on failure + r.reconcileAutoRollback(ctx, &instance) + } // Update status r.updateInstanceStatus(ctx, &instance, reconcileErr) @@ -132,6 +138,29 @@ func (r *LiteLLMInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ func (r *LiteLLMInstanceReconciler) reconcileMigrationStatus(ctx context.Context, instance *litellmv1alpha1.LiteLLMInstance, labels map[string]string) { log := logf.FromContext(ctx) + // An externally-managed proxy owns its own schema. LiteLLM migrates on + // startup, and whatever deployed it (a Helm chart, a GitOps pipeline) has + // its own migration hook — a second migrator would race the real one. + // Worse, the migration Job takes its image from spec.image.tag, which for + // an unmanaged instance describes nothing the operator deployed and + // defaults to "latest": the operator would run prisma at an arbitrary + // schema version against a database it does not own. + if !workloadManaged(instance) { + message := "Schema is owned by the externally-managed proxy" + if instance.Spec.Database.Migration != nil && instance.Spec.Database.Migration.Enabled { + message = "spec.database.migration is ignored while workload.managed is false; " + + "schema is owned by the externally-managed proxy" + } + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: ConditionDatabaseReady, + Status: metav1.ConditionTrue, + Reason: "WorkloadUnmanaged", + Message: message, + ObservedGeneration: instance.Generation, + }) + return + } + if instance.Spec.Database.Migration == nil || !instance.Spec.Database.Migration.Enabled { meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ Type: ConditionDatabaseReady, @@ -1090,35 +1119,42 @@ func (r *LiteLLMInstanceReconciler) setPodsHealthyCondition(ctx context.Context, } func (r *LiteLLMInstanceReconciler) updateInstanceStatus(ctx context.Context, instance *litellmv1alpha1.LiteLLMInstance, reconcileErr error) { - // Fetch deployment status - var dep appsv1.Deployment - if err := r.Get(ctx, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, &dep); err == nil { - instance.Status.Replicas = dep.Status.Replicas - instance.Status.ReadyReplicas = dep.Status.ReadyReplicas - instance.Status.Ready = dep.Status.ReadyReplicas > 0 - } - - // Explain pod-level faults behind an unready workload. - r.setPodsHealthyCondition(ctx, instance) + // Set endpoint first — readiness of an unmanaged proxy is probed against + // it. When the proxy serves TLS the scheme is https; this is the single + // source of the URL every controller (and the health probes) uses to reach + // the admin API, so flipping it here makes all operator calls speak TLS. + instance.Status.Endpoint = instanceEndpoint(instance) + + if workloadManaged(instance) { + // Fetch deployment status + var dep appsv1.Deployment + if err := r.Get(ctx, types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}, &dep); err == nil { + instance.Status.Replicas = dep.Status.Replicas + instance.Status.ReadyReplicas = dep.Status.ReadyReplicas + instance.Status.Ready = dep.Status.ReadyReplicas > 0 + } - // Set endpoint. When the proxy serves TLS the scheme is https — this is - // the single source of the URL every controller (and the health probes) - // uses to reach the admin API, so flipping it here makes all operator - // calls speak TLS. - port := instance.Spec.Service.Port - if port == 0 { - port = 4000 - } - scheme := "http" - if instanceServesTLS(instance) { - scheme = "https" + // Explain pod-level faults behind an unready workload. + r.setPodsHealthyCondition(ctx, instance) + } else { + // No Deployment to look at: the proxy may be a StatefulSet, live in + // another namespace, or sit outside the cluster entirely. Answering + // the admin API is the readiness signal that holds in every case. + instance.Status.Ready = r.proxyReachable(ctx, instance) + instance.Status.Replicas = 0 + instance.Status.ReadyReplicas = 0 + instance.Status.UnhealthyPods = nil + meta.RemoveStatusCondition(&instance.Status.Conditions, ConditionPodsHealthy) } - instance.Status.Endpoint = fmt.Sprintf("%s://%s.%s.svc:%d", scheme, instance.Name, instance.Namespace, port) - // Set version - instance.Status.Version = instance.Spec.Image.Tag - if instance.Status.Version == "" { - instance.Status.Version = "latest" + // Set version. For an unmanaged proxy the image tag describes nothing the + // operator deployed, so the version is left to probeInstanceHealth, which + // reads the real one off /health/readiness. + if workloadManaged(instance) { + instance.Status.Version = instance.Spec.Image.Tag + if instance.Status.Version == "" { + instance.Status.Version = "latest" + } } // SSO status @@ -1150,19 +1186,29 @@ func (r *LiteLLMInstanceReconciler) updateInstanceStatus(ctx context.Context, in emitEvent(r.Recorder, instance, corev1.EventTypeWarning, EventReasonReconcileFailed, "Reconcile failed: %v", reconcileErr) } else if instance.Status.Ready { + reason, message := "AllResourcesReady", "All managed resources are ready" + if !workloadManaged(instance) { + reason, message = "ProxyReachable", + fmt.Sprintf("Attached to externally-managed proxy at %s", instance.Status.Endpoint) + } meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ Type: ConditionReady, Status: metav1.ConditionTrue, - Reason: "AllResourcesReady", - Message: "All managed resources are ready", + Reason: reason, + Message: message, ObservedGeneration: instance.Generation, }) } else { + reason, message := "DeploymentNotReady", "Waiting for deployment to become ready" + if !workloadManaged(instance) { + reason, message = "ProxyNotReachable", + fmt.Sprintf("Externally-managed proxy at %s did not answer", instance.Status.Endpoint) + } meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ Type: ConditionReady, Status: metav1.ConditionFalse, - Reason: "DeploymentNotReady", - Message: "Waiting for deployment to become ready", + Reason: reason, + Message: message, ObservedGeneration: instance.Generation, }) } @@ -1308,6 +1354,34 @@ func (r *LiteLLMInstanceReconciler) checkEnterpriseFeaturesWarning(instance *lit } } +// proxyReachable reports whether the LiteLLM admin API answers at +// status.endpoint. This is the readiness signal for an instance whose workload +// the operator does not manage, where there is no Deployment to inspect. +// +// ponytail: costs one extra liveness call per reconcile, because +// probeInstanceHealth repeats it once this returns true. Fold the two together +// if a 60s liveness request per instance ever matters. +func (r *LiteLLMInstanceReconciler) proxyReachable(ctx context.Context, instance *litellmv1alpha1.LiteLLMInstance) bool { + if r.LiteLLMClientFactory == nil { + return false + } + ref := masterKeyRef(instance) + if ref == nil { + return false + } + masterKey, err := getSecretValue(ctx, r.Client, instance.Namespace, ref) + if err != nil { + logf.FromContext(ctx).V(1).Info("cannot resolve master key for unmanaged proxy", "error", err) + return false + } + + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + api := r.LiteLLMClientFactory(instance.Status.Endpoint, masterKey, + litellm.WithCACert(operatorProxyCACert(ctx, r.Client, instance))) + return api.Health().CheckLiveness(probeCtx) == nil +} + // probeInstanceHealth calls /health/liveliness and /health/readiness on the // running proxy, updates the Ready / RedisReady conditions, and emits // Kubernetes Events on transitions. The master key is resolved the same way @@ -1316,17 +1390,11 @@ func (r *LiteLLMInstanceReconciler) checkEnterpriseFeaturesWarning(instance *lit func (r *LiteLLMInstanceReconciler) probeInstanceHealth(ctx context.Context, instance *litellmv1alpha1.LiteLLMInstance) { log := logf.FromContext(ctx) - masterKeyRef := instance.Spec.MasterKey.SecretRef - if masterKeyRef == nil && instance.Spec.MasterKey.AutoGenerate { - masterKeyRef = &litellmv1alpha1.SecretKeyRef{ - Name: instance.Name + "-master-key", - Key: "master-key", - } - } - if masterKeyRef == nil { + ref := masterKeyRef(instance) + if ref == nil { return } - masterKey, err := getSecretValue(ctx, r.Client, instance.Namespace, masterKeyRef) + masterKey, err := getSecretValue(ctx, r.Client, instance.Namespace, ref) if err != nil { log.V(1).Info("health probe skipped, cannot resolve master key", "error", err) return @@ -1354,7 +1422,8 @@ func (r *LiteLLMInstanceReconciler) probeInstanceHealth(ctx context.Context, ins return } - if _, err := api.Health().Readiness(probeCtx); err != nil { + readiness, err := api.Health().Readiness(probeCtx) + if err != nil { meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ Type: ConditionReady, Status: metav1.ConditionFalse, @@ -1369,6 +1438,16 @@ func (r *LiteLLMInstanceReconciler) probeInstanceHealth(ctx context.Context, ins return } + // For an unmanaged proxy there is no image tag the operator chose, so the + // only truthful version is the one the proxy reports. LiteLLM only puts + // litellm_version in the readiness payload when its own general_settings + // sets allow_public_health_readiness_details: true — the endpoint takes no + // auth, so the master key does not unlock it. Absent that, status.version + // stays empty, which is honest: the operator does not know. + if !workloadManaged(instance) && readiness != nil && readiness.LiteLLMVersion != "" { + instance.Status.Version = readiness.LiteLLMVersion + } + if !previouslyHealthy { emitEvent(r.Recorder, instance, corev1.EventTypeNormal, EventReasonHealthRestored, "LiteLLM instance is healthy again") diff --git a/internal/controller/litellminstance_controller_test.go b/internal/controller/litellminstance_controller_test.go index 8c0658d..f07bb73 100644 --- a/internal/controller/litellminstance_controller_test.go +++ b/internal/controller/litellminstance_controller_test.go @@ -90,4 +90,51 @@ var _ = Describe("LiteLLMInstance Controller", func() { Expect(err).NotTo(HaveOccurred()) }) }) + + // spec.workload.endpoint is only meaningful for a proxy the operator does + // not deploy; a CEL rule on the CRD enforces that. Exercised here because + // only envtest runs API-server validation. + Context("When validating spec.workload", func() { + ctx := context.Background() + + instanceWith := func(name string, workload *litellmv1alpha1.WorkloadSpec) *litellmv1alpha1.LiteLLMInstance { + return &litellmv1alpha1.LiteLLMInstance{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: litellmv1alpha1.LiteLLMInstanceSpec{ + Workload: workload, + MasterKey: litellmv1alpha1.MasterKeySpec{SecretRef: &litellmv1alpha1.SecretKeyRef{Name: "mk", Key: "k"}}, + }, + } + } + + It("should reject an endpoint on a managed workload", func() { + err := k8sClient.Create(ctx, instanceWith("wl-managed-endpoint", &litellmv1alpha1.WorkloadSpec{ + Managed: boolPtr(true), Endpoint: "http://litellm.platform.svc:4000", + })) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("workload.endpoint is only valid when workload.managed is false")) + }) + + It("should accept an endpoint on an unmanaged workload", func() { + resource := instanceWith("wl-unmanaged-endpoint", &litellmv1alpha1.WorkloadSpec{ + Managed: boolPtr(false), Endpoint: "http://litellm.platform.svc:4000", + }) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + DeferCleanup(func() { Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) + }) + + It("should reject an endpoint that is not an http(s) URL", func() { + err := k8sClient.Create(ctx, instanceWith("wl-bad-scheme", &litellmv1alpha1.WorkloadSpec{ + Managed: boolPtr(false), Endpoint: "litellm.platform.svc:4000", + })) + Expect(err).To(HaveOccurred()) + }) + + It("should default workload.managed to true", func() { + resource := instanceWith("wl-default", &litellmv1alpha1.WorkloadSpec{}) + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + DeferCleanup(func() { Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) + Expect(resource.Spec.Workload.Managed).To(HaveValue(BeTrue())) + }) + }) }) diff --git a/internal/controller/litellminstance_resources_test.go b/internal/controller/litellminstance_resources_test.go index c0c0885..3e0d40b 100644 --- a/internal/controller/litellminstance_resources_test.go +++ b/internal/controller/litellminstance_resources_test.go @@ -21,6 +21,7 @@ import ( "testing" appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -48,6 +49,9 @@ func instanceResourceTestScheme(t *testing.T) *runtime.Scheme { if err := appsv1.AddToScheme(scheme); err != nil { t.Fatal(err) } + if err := batchv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } if err := corev1.AddToScheme(scheme); err != nil { t.Fatal(err) } diff --git a/internal/controller/litellminstance_unmanaged_test.go b/internal/controller/litellminstance_unmanaged_test.go new file mode 100644 index 0000000..a5f16e7 --- /dev/null +++ b/internal/controller/litellminstance_unmanaged_test.go @@ -0,0 +1,371 @@ +/* +Copyright 2026 bitkaio LLC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/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/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + litellmv1alpha1 "github.com/PalenaAI/litellm-operator/api/v1alpha1" + "github.com/PalenaAI/litellm-operator/internal/litellm" +) + +func boolPtr(b bool) *bool { return &b } + +// unmanagedInstance is a LiteLLMInstance attached to a proxy the operator did +// not deploy: no image, no database, just a master key and workload.managed=false. +func unmanagedInstance(endpoint string) *litellmv1alpha1.LiteLLMInstance { + return &litellmv1alpha1.LiteLLMInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "litellm", Namespace: "default", UID: types.UID("instance-uid")}, + Spec: litellmv1alpha1.LiteLLMInstanceSpec{ + Workload: &litellmv1alpha1.WorkloadSpec{Managed: boolPtr(false), Endpoint: endpoint}, + MasterKey: litellmv1alpha1.MasterKeySpec{ + SecretRef: &litellmv1alpha1.SecretKeyRef{Name: "master-key", Key: "LITELLM_MASTER_KEY"}, + }, + }, + } +} + +func masterKeySecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "master-key", Namespace: "default"}, + Data: map[string][]byte{"LITELLM_MASTER_KEY": []byte("sk-test")}, + } +} + +// reconcileUnmanaged runs the instance reconciler to completion (the first pass +// only adds the finalizer) and returns the client plus the reconciled instance. +func reconcileUnmanaged( + t *testing.T, + instance *litellmv1alpha1.LiteLLMInstance, + livenessErr error, + extra ...client.Object, +) (client.Client, *litellmv1alpha1.LiteLLMInstance) { + t.Helper() + scheme := instanceResourceTestScheme(t) + objs := append([]client.Object{instance, masterKeySecret()}, extra...) + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&litellmv1alpha1.LiteLLMInstance{}). + Build() + + mock := litellm.NewMockClient() + mock.MockHealth.CheckLivenessFunc = func(context.Context) error { return livenessErr } + + r := &LiteLLMInstanceReconciler{ + Client: c, + Scheme: scheme, + LiteLLMClientFactory: func(string, string, ...litellm.ClientOption) litellm.Client { return mock }, + } + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}} + for i := 0; i < 2; i++ { + if _, err := r.Reconcile(context.Background(), req); err != nil { + t.Fatalf("reconcile: %v", err) + } + } + + var out litellmv1alpha1.LiteLLMInstance + if err := c.Get(context.Background(), req.NamespacedName, &out); err != nil { + t.Fatalf("get instance: %v", err) + } + return c, &out +} + +// The whole point of workload.managed=false: the operator provisions nothing. +func TestUnmanagedWorkloadCreatesNoResources(t *testing.T) { + c, _ := reconcileUnmanaged(t, unmanagedInstance(""), nil) + + key := types.NamespacedName{Name: "litellm", Namespace: "default"} + for name, obj := range map[string]client.Object{ + "Deployment": &appsv1.Deployment{}, + "Service": &corev1.Service{}, + "ConfigMap": &corev1.ConfigMap{}, + "ServiceAccount": &corev1.ServiceAccount{}, + } { + err := c.Get(context.Background(), key, obj) + if !apierrors.IsNotFound(err) { + t.Errorf("%s: want NotFound, got %v", name, err) + } + } +} + +// Attaching to a proxy someone else owns must not adopt or mutate it. The +// name collides deliberately — that is the shape reconcileDeployment used to +// overwrite by name. +func TestUnmanagedWorkloadLeavesForeignObjectsUntouched(t *testing.T) { + foreign := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "litellm", Namespace: "default", + Labels: map[string]string{"app.kubernetes.io/managed-by": "Helm"}, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "helm-litellm"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "helm-litellm"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "litellm", Image: "helm/litellm:1.2.3"}}}, + }, + }, + Status: appsv1.DeploymentStatus{Replicas: 3, ReadyReplicas: 3}, + } + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "litellm", Namespace: "default"}, + Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 80, Name: "http"}}}, + } + + c, _ := reconcileUnmanaged(t, unmanagedInstance(""), nil, foreign, svc) + + var got appsv1.Deployment + if err := c.Get(context.Background(), types.NamespacedName{Name: "litellm", Namespace: "default"}, &got); err != nil { + t.Fatalf("get deployment: %v", err) + } + if got.ResourceVersion != foreign.ResourceVersion { + t.Errorf("deployment was mutated: resourceVersion %s -> %s", foreign.ResourceVersion, got.ResourceVersion) + } + if got.Spec.Template.Spec.Containers[0].Image != "helm/litellm:1.2.3" { + t.Errorf("deployment image overwritten: %s", got.Spec.Template.Spec.Containers[0].Image) + } + if len(got.OwnerReferences) != 0 { + t.Errorf("foreign deployment adopted: %v", got.OwnerReferences) + } +} + +// A migration Job is a write to persistent state the operator does not own, at +// a schema version taken from spec.image.tag — meaningless for a proxy the +// operator did not deploy. It must never run, even when explicitly configured, +// and saying so beats skipping it silently. +func TestUnmanagedWorkloadNeverRunsMigrations(t *testing.T) { + for _, tc := range []struct { + name string + migration *litellmv1alpha1.MigrationSpec + wantMsg string + }{ + {"no migration block", nil, "Schema is owned by the externally-managed proxy"}, + {"migration explicitly enabled", &litellmv1alpha1.MigrationSpec{Enabled: true}, "is ignored"}, + } { + t.Run(tc.name, func(t *testing.T) { + instance := unmanagedInstance("http://litellm.platform.svc:4000") + instance.Spec.Database.Migration = tc.migration + c, out := reconcileUnmanaged(t, instance, nil) + + var jobs batchv1.JobList + if err := c.List(context.Background(), &jobs); err != nil { + t.Fatalf("list jobs: %v", err) + } + if len(jobs.Items) != 0 { + t.Errorf("migration Job created for an unmanaged workload: %v", jobs.Items) + } + + cond := meta.FindStatusCondition(out.Status.Conditions, ConditionDatabaseReady) + if cond == nil || cond.Reason != "WorkloadUnmanaged" { + t.Fatalf("DatabaseReady condition = %+v, want reason WorkloadUnmanaged", cond) + } + if !strings.Contains(cond.Message, tc.wantMsg) { + t.Errorf("DatabaseReady message = %q, want it to contain %q", cond.Message, tc.wantMsg) + } + }) + } +} + +// Readiness of an unmanaged proxy comes from the admin API answering, not from +// a Deployment that may not exist (StatefulSet, other namespace, off-cluster). +func TestUnmanagedReadinessFollowsLivenessProbe(t *testing.T) { + for _, tc := range []struct { + name string + livenessErr error + wantReady bool + wantReason string + }{ + {"proxy answers", nil, true, "ProxyReachable"}, + {"proxy down", errors.New("connection refused"), false, "ProxyNotReachable"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, out := reconcileUnmanaged(t, unmanagedInstance("http://litellm.platform.svc:4000"), tc.livenessErr) + + if out.Status.Ready != tc.wantReady { + t.Errorf("status.ready = %v, want %v", out.Status.Ready, tc.wantReady) + } + cond := meta.FindStatusCondition(out.Status.Conditions, ConditionReady) + if cond == nil || cond.Reason != tc.wantReason { + t.Errorf("Ready condition = %+v, want reason %q", cond, tc.wantReason) + } + // No workload of ours, so no pod-level condition to report. + if meta.FindStatusCondition(out.Status.Conditions, ConditionPodsHealthy) != nil { + t.Error("PodsHealthy condition set for an unmanaged workload") + } + }) + } +} + +// status.version for an unmanaged instance must never be spec.image.tag, which +// describes nothing the operator deployed and would print a fabricated +// "latest". LiteLLM only discloses litellm_version on /health/readiness when +// its own general_settings sets allow_public_health_readiness_details; the +// endpoint takes no auth, so the master key does not unlock it. Both shapes of +// payload have to behave. +func TestUnmanagedVersionComesFromTheProxy(t *testing.T) { + for _, tc := range []struct { + name string + readiness *litellm.ReadinessResponse + want string + }{ + { + // allow_public_health_readiness_details: true + name: "detailed payload reports the running version", + readiness: &litellm.ReadinessResponse{Status: "healthy", LiteLLMVersion: "1.93.0"}, + want: "1.93.0", + }, + { + // The default payload: {"status": ..., "db": ...}. Empty is honest — + // the operator does not know, and must not invent "latest". + name: "minimal payload leaves the version empty", + readiness: &litellm.ReadinessResponse{Status: "healthy", DBHealth: "connected"}, + want: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + scheme := instanceResourceTestScheme(t) + instance := unmanagedInstance("http://litellm.platform.svc:4000") + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(instance, masterKeySecret()). + WithStatusSubresource(&litellmv1alpha1.LiteLLMInstance{}). + Build() + + mock := litellm.NewMockClient() + mock.MockHealth.ReadinessFunc = func(context.Context) (*litellm.ReadinessResponse, error) { + return tc.readiness, nil + } + r := &LiteLLMInstanceReconciler{ + Client: c, + Scheme: scheme, + LiteLLMClientFactory: func(string, string, ...litellm.ClientOption) litellm.Client { return mock }, + } + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: instance.Name, Namespace: instance.Namespace}} + for i := 0; i < 2; i++ { + if _, err := r.Reconcile(context.Background(), req); err != nil { + t.Fatalf("reconcile: %v", err) + } + } + + var out litellmv1alpha1.LiteLLMInstance + if err := c.Get(context.Background(), req.NamespacedName, &out); err != nil { + t.Fatalf("get instance: %v", err) + } + if out.Status.Version != tc.want { + t.Errorf("status.version = %q, want %q", out.Status.Version, tc.want) + } + }) + } +} + +// A name-matched Deployment must not make an unreachable proxy look ready. +func TestUnmanagedReadinessIgnoresNameMatchedDeployment(t *testing.T) { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "litellm", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "x"}}, + Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "x"}}}, + }, + Status: appsv1.DeploymentStatus{Replicas: 2, ReadyReplicas: 2}, + } + _, out := reconcileUnmanaged(t, unmanagedInstance(""), errors.New("connection refused"), dep) + + if out.Status.Ready { + t.Error("status.ready = true from a Deployment we do not manage") + } +} + +func TestWorkloadManaged(t *testing.T) { + for _, tc := range []struct { + name string + spec *litellmv1alpha1.WorkloadSpec + want bool + }{ + {"absent defaults to managed", nil, true}, + {"unset defaults to managed", &litellmv1alpha1.WorkloadSpec{}, true}, + {"managed true", &litellmv1alpha1.WorkloadSpec{Managed: boolPtr(true)}, true}, + {"managed false", &litellmv1alpha1.WorkloadSpec{Managed: boolPtr(false)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + i := &litellmv1alpha1.LiteLLMInstance{Spec: litellmv1alpha1.LiteLLMInstanceSpec{Workload: tc.spec}} + if got := workloadManaged(i); got != tc.want { + t.Errorf("workloadManaged = %v, want %v", got, tc.want) + } + }) + } +} + +func TestInstanceEndpoint(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*litellmv1alpha1.LiteLLMInstance) + expected string + }{ + { + name: "defaults to the in-cluster Service on port 4000", + mutate: func(*litellmv1alpha1.LiteLLMInstance) {}, + expected: "http://litellm.default.svc:4000", + }, + { + name: "honours spec.service.port", + mutate: func(i *litellmv1alpha1.LiteLLMInstance) { + i.Spec.Service = litellmv1alpha1.ServiceSpec{Port: 80} + }, + expected: "http://litellm.default.svc:80", + }, + { + name: "https when the proxy serves TLS", + mutate: func(i *litellmv1alpha1.LiteLLMInstance) { + i.Spec.TLS = &litellmv1alpha1.TLSSpec{ + ServerCertSecretRef: &litellmv1alpha1.SecretRef{Name: "serving-cert"}, + } + }, + expected: "https://litellm.default.svc:4000", + }, + { + name: "explicit workload.endpoint wins", + mutate: func(i *litellmv1alpha1.LiteLLMInstance) { + i.Spec.Workload = &litellmv1alpha1.WorkloadSpec{ + Managed: boolPtr(false), Endpoint: "https://litellm.platform.svc:443", + } + }, + expected: "https://litellm.platform.svc:443", + }, + } { + t.Run(tc.name, func(t *testing.T) { + i := &litellmv1alpha1.LiteLLMInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "litellm", Namespace: "default"}, + } + tc.mutate(i) + if got := instanceEndpoint(i); got != tc.expected { + t.Errorf("instanceEndpoint = %q, want %q", got, tc.expected) + } + }) + } +}