Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/v1alpha1/olsconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ type OLSSpec struct {
// +kubebuilder:validation:Optional
// +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Tools Approval Configuration",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"}
ToolsApprovalConfig *ToolsApprovalConfig `json:"toolsApprovalConfig,omitempty"`
// Enable in-process credential hot-reload for LLM provider secrets.
// When true, the operator will not restart the app-server when LLM credential
// secret data is rotated — the service re-reads credentials from disk on each request.
// IMPORTANT: Requires lightspeed-service with get_credentials() hot-reload support
// (service PR #2955 / RFE-9380). If enabled with an older service image, rotated
// credentials (including revoked keys) will remain stale until the pod is manually restarted.
// +kubebuilder:default=false
// +kubebuilder:validation:Optional
// +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Credential Hot Reload",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"}
CredentialHotReload *bool `json:"credentialHotReload,omitempty"`
}

// Persistent Storage Configuration
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/ols.openshift.io_olsconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,16 @@ spec:
- postgres
type: string
type: object
credentialHotReload:
default: false
description: |-
Enable in-process credential hot-reload for LLM provider secrets.
When true, the operator will not restart the app-server when LLM credential
secret data is rotated — the service re-reads credentials from disk on each request.
IMPORTANT: Requires lightspeed-service with get_credentials() hot-reload support
(service PR #2955 / RFE-9380). If enabled with an older service image, rotated
credentials (including revoked keys) will remain stale until the pod is manually restarted.
type: boolean
defaultModel:
description: Default model for usage
type: string
Expand Down
52 changes: 52 additions & 0 deletions docs/credential-hot-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Credential Hot-Reload (RFE-9380)

## Overview

When `credentialHotReload` is enabled on the OLSConfig CR, the operator skips
rolling restarts of the app-server pod when LLM credential secret **data**
changes. The service re-reads credential files from disk on every LLM request,
so rotated tokens take effect without downtime.

This feature is a companion to
[lightspeed-service PR #2955](https://github.com/openshift/lightspeed-service/pull/2955),
which adds the in-process credential reload on the service side.

## Configuration

```yaml
apiVersion: ols.openshift.io/v1alpha1
kind: OLSConfig
metadata:
name: cluster
spec:
ols:
credentialHotReload: true # default: false
```

## Behavior

| Event | `credentialHotReload: false` (default) | `credentialHotReload: true` |
|---|---|---|
| LLM secret **data** rotated (same secret name) | Rolling restart | **No restart** — service picks up new credentials on next request |
| LLM secret **ref** changed in CR (different secret name) | Rolling restart | Rolling restart (deployment volume spec changes) |
| TLS / MCP / Postgres secret changed | Rolling restart | Rolling restart (unchanged) |

## Prerequisites

- The lightspeed-service image must include the `get_credentials()` hot-reload
support (lightspeed-service >= the version containing PR #2955). If an older
service image is used with this flag enabled, rotated credentials will not be
picked up until the pod is manually restarted.

## How It Works

1. During reconciliation, the operator reads `spec.ols.credentialHotReload` from
the OLSConfig CR and stores it in the internal `WatcherConfig`, along with the
set of LLM provider secret names.

2. When the secret watcher detects a `.data` change on an annotated secret, it
checks whether the secret is an LLM credential and the hot-reload flag is
enabled. If both conditions are true, the restart is skipped.

3. Non-LLM secrets (TLS, MCP headers, Postgres) always trigger restarts
regardless of the flag.
6 changes: 6 additions & 0 deletions internal/controller/olsconfig_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,12 @@ func (r *OLSConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
return ctrl.Result{}, fmt.Errorf("failed to annotate external resources: %w", err)
}

if olsconfig.Spec.OLSConfig.CredentialHotReload != nil && *olsconfig.Spec.OLSConfig.CredentialHotReload {
r.Logger.Info("credentialHotReload is enabled — LLM credential secret rotations will not "+
"trigger app-server restarts. Requires lightspeed-service with get_credentials() "+
"hot-reload support (service PR #2955 / RFE-9380)")
}

// 5. Phase 1: Reconcile independent resources
if err := r.reconcileIndependentResources(ctx, olsconfig); err != nil {
return ctrl.Result{}, err
Expand Down
10 changes: 10 additions & 0 deletions internal/controller/olsconfig_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,16 @@ func (r *OLSConfigReconciler) annotateExternalResources(ctx context.Context,
if r.WatcherConfig != nil {
r.WatcherConfig.AnnotatedConfigMapMapping = make(map[string][]string)
r.WatcherConfig.AnnotatedSecretMapping = make(map[string][]string)
r.WatcherConfig.LLMSecretNames = make(map[string]bool)

r.WatcherConfig.CredentialHotReload = cr.Spec.OLSConfig.CredentialHotReload != nil &&
*cr.Spec.OLSConfig.CredentialHotReload

for _, provider := range cr.Spec.LLMConfig.Providers {
if provider.CredentialsSecretRef.Name != "" {
r.WatcherConfig.LLMSecretNames[provider.CredentialsSecretRef.Name] = true
}
}
}

var errs []error
Expand Down
9 changes: 8 additions & 1 deletion internal/controller/utils/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,19 @@ type ConfigMapWatcherConfig struct {
SystemResources []SystemConfigMap
}

// WatcherConfig contains all watcher configuration
// WatcherConfig contains all watcher configuration.
// NOTE: This struct is written by the reconciler and read by watcher event handlers
// (including predicate filters such as SecretWatcherFilter). This is safe because
// controller-runtime serializes reconcile calls and predicate evaluations on the same
// controller work queue. If MaxConcurrentReconciles is ever increased above 1,
// a sync.RWMutex must be added here.
type WatcherConfig struct {
Secrets SecretWatcherConfig
ConfigMaps ConfigMapWatcherConfig
AnnotatedSecretMapping map[string][]string
AnnotatedConfigMapMapping map[string][]string
CredentialHotReload bool
LLMSecretNames map[string]bool
}
Comment on lines +60 to 73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does controller-runtime call Predicate.Update/Create in the same goroutine as Reconcile, or in the informer event handler goroutine?

💡 Result:

In controller-runtime, Predicate functions (Create, Update, Delete, Generic) are executed in the informer event handler goroutine, not in the goroutine that runs the Reconcile function [1][2]. Predicates operate within the informer event path, where they serve as filters that determine whether an event should be enqueued for reconciliation [1][3]. Because they execute before a request enters the workqueue, they must be efficient, non-blocking, and should not perform expensive computations or API calls [1]. In contrast, the Reconcile function runs in a separate pool of worker goroutines dedicated to processing the workqueue [1][4]. The number of concurrent Reconcile goroutines is controlled by the MaxConcurrentReconciles configuration [1]. The separation ensures that the critical path of informer event processing remains fast, preventing bottlenecks from propagating back to the informer's event stream [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and find all uses of WatcherConfig / LLMSecretNames / CredentialHotReload.
ast-grep outline internal/controller/utils/types.go --view expanded || true
printf '\n--- uses ---\n'
rg -n "WatcherConfig|LLMSecretNames|CredentialHotReload|SecretWatcherFilter|annotateExternalResources" internal/controller -S

printf '\n--- types.go excerpt ---\n'
sed -n '1,160p' internal/controller/utils/types.go

printf '\n--- relevant controller excerpts ---\n'
for f in $(rg -l "WatcherConfig|LLMSecretNames|CredentialHotReload|SecretWatcherFilter|annotateExternalResources" internal/controller -S); do
  echo "### $f"
  sed -n '1,260p' "$f"
done

Repository: openshift/lightspeed-operator

Length of output: 50386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- watcher filter ---\n'
sed -n '220,340p' internal/controller/watchers/watchers.go

printf '\n--- annotateExternalResources ---\n'
sed -n '360,420p' internal/controller/olsconfig_helpers.go

printf '\n--- any synchronization around WatcherConfig ---\n'
rg -n "RWMutex|Mutex|Lock\\(|RLock\\(|WatcherConfig" internal/controller -S

printf '\n--- setup/watch registration ---\n'
sed -n '520,700p' internal/controller/olsconfig_controller.go

Repository: openshift/lightspeed-operator

Length of output: 258


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- watcher filter ---'
sed -n '220,340p' internal/controller/watchers/watchers.go

echo
echo '--- annotateExternalResources ---'
sed -n '360,420p' internal/controller/olsconfig_helpers.go

echo
echo '--- synchronization search ---'
rg -n "RWMutex|Mutex|Lock\\(|RLock\\(|WatcherConfig" internal/controller -S

echo
echo '--- watch registration ---'
sed -n '520,700p' internal/controller/olsconfig_controller.go

Repository: openshift/lightspeed-operator

Length of output: 21680


Protect WatcherConfig from concurrent access. SecretWatcherFilter() reads CredentialHotReload and LLMSecretNames while annotateExternalResources() rewrites them during reconcile on a different goroutine. This shared state needs a mutex or immutable snapshot; otherwise a secret event can race with reconcile and hit concurrent map access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/utils/types.go` around lines 60 - 73, `WatcherConfig` is
shared mutable state and is accessed concurrently by `SecretWatcherFilter()`
while `annotateExternalResources()` updates `CredentialHotReload` and
`LLMSecretNames`; add synchronization or switch to an immutable snapshot
approach. Update the `WatcherConfig` type in `types.go` to guard these fields
with a mutex (or refactor to store/retrieve a copied config atomically), and
make both `SecretWatcherFilter()` and `annotateExternalResources()` use the same
protection when reading or writing `CredentialHotReload` and `LLMSecretNames`.


/*** controller internal ***/
Expand Down
13 changes: 12 additions & 1 deletion internal/controller/watchers/watchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,19 @@ func SecretWatcherFilter(r reconciler.Reconciler, ctx context.Context, obj clien

// Check 2: Look for watcher annotation (user-provided secrets)
if _, exist := annotations[utils.WatcherAnnotationKey]; exist {
// For annotated secrets, determine affected deployments from mapping
secretName := obj.GetName()

// Skip restart for LLM credential secrets when hot-reload is enabled.
// The service re-reads credentials from disk on every request (RFE-9380).
if watcherConfig != nil && watcherConfig.CredentialHotReload {
if watcherConfig.LLMSecretNames[secretName] {
r.GetLogger().Info("Skipping restart for LLM credential secret (hot-reload enabled)",
"secret", secretName)
return
}
}

// For annotated secrets, determine affected deployments from mapping
var affectedDeployments []string
var found bool
if watcherConfig != nil {
Expand Down
110 changes: 110 additions & 0 deletions internal/controller/watchers/watchers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,114 @@ var _ = Describe("Watchers", func() {
Expect(updated.Spec.Template.Annotations).To(HaveKey(utils.ForceReloadAnnotationKey))
})
})

Describe("Credential hot-reload", func() {
It("skips restart for LLM secret when CredentialHotReload is enabled", func() {
cr := utils.GetDefaultOLSConfigCR()
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: utils.OLSAppServerDeploymentName,
Namespace: utils.OLSNamespaceDefault,
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "ols"}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "ols"}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "img"}}},
},
},
}
r := createTestReconciler(cr, dep)

wc, _ := r.GetWatcherConfig().(*utils.WatcherConfig)
wc.CredentialHotReload = true
wc.LLMSecretNames = map[string]bool{"test-secret": true}

sec := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: utils.OLSNamespaceDefault,
Name: "test-secret",
Annotations: map[string]string{utils.WatcherAnnotationKey: "1"},
},
Data: map[string][]byte{"apitoken": []byte("rotated-key")},
}
SecretWatcherFilter(r, ctx, sec, true)

updated := &appsv1.Deployment{}
Expect(r.Get(ctx, client.ObjectKeyFromObject(dep), updated)).To(Succeed())
Expect(updated.Spec.Template.Annotations).NotTo(HaveKey(utils.ForceReloadAnnotationKey))
})

It("restarts for LLM secret when CredentialHotReload is disabled", func() {
cr := utils.GetDefaultOLSConfigCR()
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: utils.OLSAppServerDeploymentName,
Namespace: utils.OLSNamespaceDefault,
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "ols"}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "ols"}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "img"}}},
},
},
}
r := createTestReconciler(cr, dep)

wc, _ := r.GetWatcherConfig().(*utils.WatcherConfig)
wc.CredentialHotReload = false
wc.LLMSecretNames = map[string]bool{"test-secret": true}

sec := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: utils.OLSNamespaceDefault,
Name: "test-secret",
Annotations: map[string]string{utils.WatcherAnnotationKey: "1"},
},
Data: map[string][]byte{"apitoken": []byte("rotated-key")},
}
SecretWatcherFilter(r, ctx, sec, true)

updated := &appsv1.Deployment{}
Expect(r.Get(ctx, client.ObjectKeyFromObject(dep), updated)).To(Succeed())
Expect(updated.Spec.Template.Annotations).To(HaveKey(utils.ForceReloadAnnotationKey))
})

It("restarts for non-LLM secret even when CredentialHotReload is enabled", func() {
cr := utils.GetDefaultOLSConfigCR()
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: utils.OLSAppServerDeploymentName,
Namespace: utils.OLSNamespaceDefault,
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "ols"}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "ols"}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "c", Image: "img"}}},
},
},
}
r := createTestReconciler(cr, dep)

wc, _ := r.GetWatcherConfig().(*utils.WatcherConfig)
wc.CredentialHotReload = true
wc.LLMSecretNames = map[string]bool{"test-secret": true}

sec := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: utils.OLSNamespaceDefault,
Name: "tls-secret",
Annotations: map[string]string{utils.WatcherAnnotationKey: "1"},
},
Data: map[string][]byte{"tls.crt": []byte("cert-data")},
}
SecretWatcherFilter(r, ctx, sec, true)

updated := &appsv1.Deployment{}
Expect(r.Get(ctx, client.ObjectKeyFromObject(dep), updated)).To(Succeed())
Expect(updated.Spec.Template.Annotations).To(HaveKey(utils.ForceReloadAnnotationKey))
})
})
})
55 changes: 55 additions & 0 deletions test/e2e/reconciliation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,59 @@ var _ = Describe("Reconciliation From OLSConfig CR", Ordered, func() {

})

It("should skip app-server restart on LLM secret data rotation when credentialHotReload is enabled", func() {
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: AppServerDeploymentName,
Namespace: OLSNameSpace,
},
}

By("enable credentialHotReload on the CR")
err = client.Update(cr, func(obj ctrlclient.Object) error {
config := obj.(*olsv1alpha1.OLSConfig)
hotReload := true
config.Spec.OLSConfig.CredentialHotReload = &hotReload
return nil
})
Expect(err).NotTo(HaveOccurred())

// credentialHotReload only affects in-memory WatcherConfig, not the
// Deployment spec, so there is no Generation bump to wait for.
// Give the reconciler time to process the CR update.
time.Sleep(5 * time.Second)

err = client.Get(deployment)
Expect(err).NotTo(HaveOccurred())
generation := deployment.Generation
Comment on lines +400 to +416

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Hard-coded sleep for reconciliation is a flakiness risk.

time.Sleep(5 * time.Second) assumes the reconciler will have processed the credentialHotReload CR update (and repopulated WatcherConfig) within 5 seconds. Under CI load or slower reconcile loops, the LLM secret rotation could race ahead of the watcher-config update, causing the subsequent Consistently check to intermittently fail (the deployment would restart because hot-reload wasn't yet active in-memory).

Since there's no Deployment/ConfigMap side-effect to poll on (per the comment), consider polling on an observable signal instead of a blind sleep — e.g., increasing the sleep with a retry/backoff, or exposing a lightweight status condition/annotation that reflects the reconciler having processed the latest spec.generation, and using Eventually against that.

💡 Possible mitigation without a new status signal
-		// credentialHotReload only affects in-memory WatcherConfig, not the
-		// Deployment spec, so there is no Generation bump to wait for.
-		// Give the reconciler time to process the CR update.
-		time.Sleep(5 * time.Second)
+		// credentialHotReload only affects in-memory WatcherConfig, not the
+		// Deployment spec, so there is no Generation bump to wait for.
+		// Poll to reduce flakiness risk vs. a fixed sleep.
+		Eventually(func() bool {
+			var current olsv1alpha1.OLSConfig
+			current.Name = cr.Name
+			if err := client.Get(&current); err != nil {
+				return false
+			}
+			return current.Spec.OLSConfig.CredentialHotReload != nil && *current.Spec.OLSConfig.CredentialHotReload
+		}, 15*time.Second, 1*time.Second).Should(BeTrue())

As per path instructions, "Flag flaky timing, missing teardown, or assumptions about optional components not deployed in bundle/operator CI jobs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/reconciliation_test.go` around lines 400 - 416, The reconciliation
test uses a fixed sleep after updating OLSConfig.CredentialHotReload, which
makes the hot-reload setup timing-dependent and flaky. Replace the blind
time.Sleep in the credentialHotReload setup with an Eventually-based wait on an
observable signal from the reconciler, such as a status condition, annotation,
or other field updated when the latest spec.generation has been processed. Use
the existing client.Update, client.Get, and deployment.Generation flow to locate
the test, and keep the later Consistently assertion only after the
reconciler-ready signal is observed.

Source: Path instructions


By("rotate the LLM secret data (using second secret — CR was switched to it by an earlier test)")
llmSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: LLMTokenSecondSecretName,
Namespace: OLSNameSpace,
},
}
err = client.Update(llmSecret, func(obj ctrlclient.Object) error {
s := obj.(*corev1.Secret)
s.Data[LLMApiTokenFileName] = []byte("rotated-key-value")
return nil
})
Expect(err).NotTo(HaveOccurred())

By("verify the deployment generation did NOT change (no restart)")
Consistently(func() int64 {
Expect(client.Get(deployment)).To(Succeed())
return deployment.Generation
}, 15*time.Second, 2*time.Second).Should(Equal(generation))

By("disable credentialHotReload to restore default behavior")
err = client.Update(cr, func(obj ctrlclient.Object) error {
config := obj.(*olsv1alpha1.OLSConfig)
config.Spec.OLSConfig.CredentialHotReload = nil
return nil
})
Expect(err).NotTo(HaveOccurred())
})

})