From 05645fe36af9f38b39771f80bd1e399a3e8e311e Mon Sep 17 00:00:00 2001 From: David Gibbons Date: Sat, 5 Sep 2026 09:44:54 -0700 Subject: [PATCH] feat(instance): attach to an externally-managed LiteLLM deployment (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `spec.workload.managed: false`, which stops the operator provisioning the proxy workload 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. Nothing is created and nothing existing is adopted or mutated, replacing the RBAC-denial workaround that worked but left the instance permanently Degraded. Adds `spec.workload.endpoint` alongside it, 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. A CEL rule rejects it when the workload is managed; the pattern requires a host, since the value becomes an outbound request URL. Readiness for an unmanaged instance now comes from the admin API answering at that endpoint rather than a name-matched Deployment, which also makes a StatefulSet-backed or off-cluster proxy work, and stops a Deployment that merely shares the CR's name from faking readiness. The Ready condition reports ProxyReachable / ProxyNotReachable and no PodsHealthy condition is set, because the operator owns no pods. status.version is not taken from spec.image.tag, which describes nothing the operator deployed and would print a fabricated "latest". It is filled from the litellm_version the proxy reports on /health/readiness, which LiteLLM includes only when its own general_settings sets allow_public_health_readiness_details: true — that endpoint is unauthenticated, so the master key does not unlock it. Absent that the field stays empty, which is honest: the operator does not know. spec.database.migration is ignored entirely. An externally-managed proxy owns its own schema: LiteLLM migrates on startup and whatever deployed it ships its own migration hook, so the operator would be a second migrator racing the real one. BuildMigrationJob also takes its image from spec.image.tag (defaulting to "latest"), so the Job would run prisma migrate deploy at an arbitrary schema version against a database the operator does not own. DatabaseReady reports WorkloadUnmanaged, and says the migration was ignored rather than skipping it silently when one was configured anyway. Health probing, config sync, license detection and finalizer-based cleanup of upstream entities are unaffected. Only workload reconciliation, auto-rollback and database migration are gated. The master-key-with-autogenerate-fallback block was duplicated in resolveInstance and probeInstanceHealth; it is now one masterKeyRef helper, which the new readiness probe also uses. Signed-off-by: David Gibbons --- CHANGELOG.md | 5 + README.md | 38 ++ api/v1alpha1/litellminstance_types.go | 37 ++ api/v1alpha1/zz_generated.deepcopy.go | 25 ++ ...itellm-operator.clusterserviceversion.yaml | 8 +- .../litellm.palena.ai_litellminstances.yaml | 29 ++ .../litellm.palena.ai_litellminstances.yaml | 29 ++ ...itellm-operator.clusterserviceversion.yaml | 6 + ...lm_v1alpha1_litellminstance_unmanaged.yaml | 31 ++ .../litellm.palena.ai_litellminstances.yaml | 29 ++ docs/changelog.md | 5 + docs/reference/litellminstance.md | 55 ++- internal/controller/common.go | 52 ++- .../controller/litellminstance_controller.go | 167 +++++--- .../litellminstance_controller_test.go | 47 +++ .../litellminstance_resources_test.go | 4 + .../litellminstance_unmanaged_test.go | 371 ++++++++++++++++++ 17 files changed, 880 insertions(+), 58 deletions(-) create mode 100644 config/samples/litellm_v1alpha1_litellminstance_unmanaged.yaml create mode 100644 internal/controller/litellminstance_unmanaged_test.go 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) + } + }) + } +}