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
3 changes: 2 additions & 1 deletion pkg/console/operator/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ type consoleOperator struct {

monitoringDeploymentLister appsv1listers.DeploymentLister

lastDeploymentAvailableTime time.Time
lastDeploymentAvailableTime time.Time
lastAppliedDeploymentGeneration int64
}

type trackables struct {
Expand Down
13 changes: 12 additions & 1 deletion pkg/console/operator/sync_v400.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,16 @@ func (co *consoleOperator) SyncDeployment(
}
deploymentsub.LogDeploymentAnnotationChanges(co.deploymentClient, requiredDeployment, ctx)

expectedGen := resourcemerge.ExpectedDeploymentGeneration(requiredDeployment, updatedOperatorConfig.Status.Generations)
// After the operator updates the deployment, the deployment informer
// triggers a re-sync before the operator config informer has processed
// the corresponding status write. The stale expected generation causes
// a no-op update and a spurious DeploymentUpdated event. Use the
// in-memory cached generation to bridge the informer cache gap.
if co.lastAppliedDeploymentGeneration > expectedGen {
expectedGen = co.lastAppliedDeploymentGeneration
}

Comment on lines +334 to +343

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

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

module_dir="$(go list -m -f '{{.Dir}}' github.com/openshift/library-go)"
module_version="$(go list -m -f '{{.Version}}' github.com/openshift/library-go)"
printf 'library-go %s (%s)\n' "$module_version" "$module_dir"

rg -n -C 6 \
  'existingCopy\.ObjectMeta\.Generation == expectedGeneration|ApplyDeploymentWithForce' \
  "$module_dir/pkg/operator/resource/resourceapply/apps.go"

Repository: openshift/console-operator

Length of output: 374


🏁 Script executed:

set -euo pipefail

printf '%s\n' '--- module declaration and replace directives ---'
sed -n '1,120p' go.mod

printf '%s\n' '--- cached-generation declarations and uses ---'
rg -n -C 8 'lastAppliedDeploymentGeneration|ExpectedDeploymentGeneration|ApplyDeployment' pkg/console/operator

printf '%s\n' '--- local module-cache locations ---'
go env GOMODCACHE GOPATH
find "$(go env GOMODCACHE)" -path '*/github.com/openshift/library-go*' -maxdepth 8 -type f \( -path '*/pkg/operator/resource/resourceapply/apps.go' -o -name 'apps.go' \) -print 2>/dev/null | head -20 || true

Repository: openshift/console-operator

Length of output: 10866


🏁 Script executed:

set -euo pipefail

library_dir="$(find "$(go env GOMODCACHE)" -path '*/github.com/openshift/library-go@v0.0.0-20260713084045-a99049bdd190' -type d -print -quit)"
printf '%s\n' "--- library-go source: $library_dir/pkg/operator/resource/resourceapply/apps.go ---"
sed -n '1,260p' "$library_dir/pkg/operator/resource/resourceapply/apps.go"

printf '%s\n' "--- expected-generation helper ---"
rg -n -C 12 'func ExpectedDeploymentGeneration|ExpectedDeploymentGeneration' \
  "$library_dir/pkg/operator/resource/resourcemerge/apps.go" \
  "$library_dir/pkg/operator/resource"

printf '%s\n' '--- surrounding sync and status flow ---'
sed -n '280,390p' pkg/console/operator/sync_v400.go
rg -n -C 10 'Generations|ObservedGeneration|lastAppliedDeploymentGeneration|ObjectMeta.UID|UID' \
  pkg/console/operator

Repository: openshift/console-operator

Length of output: 40272


🏁 Script executed:

set -euo pipefail

library_dir="$(find "$(go env GOMODCACHE)" -path '*/github.com/openshift/library-go@v0.0.0-20260713084045-a99049bdd190' -type d -print -quit)"

printf '%s\n' '--- metadata merge and update-event behavior ---'
rg -n -C 12 'func EnsureObjectMeta|func ReportUpdateEvent|DeploymentUpdated|Updated' \
  "$library_dir/pkg/operator/resource" "$library_dir/pkg/operator/events"

printf '%s\n' '--- tests for ApplyDeployment generation mismatch ---'
rg -n -C 10 'expectedGeneration|generation mismatch|DeploymentUpdated|ApplyDeployment' \
  "$library_dir/pkg/operator/resource/resourceapply" | head -240

printf '%s\n' '--- deployment informer/event consumers in this repository ---'
rg -n -C 8 'DeploymentUpdated|Deployment.*Updated|deployment.*event|deploymentInformer|WithFilteredEventsInformers|deployment.*resync' \
  pkg cmd

Repository: openshift/console-operator

Length of output: 50383


🏁 Script executed:

set -euo pipefail

library_dir="$(find "$(go env GOMODCACHE)" -path '*/github.com/openshift/library-go@v0.0.0-20260713084045-a99049bdd190' -type d -print -quit)"

python3 - <<'PY'
from pathlib import Path
import re

apps = Path("""REPLACE_APPS""")
merger = Path("""REPLACE_MERGER""")
event = Path("""REPLACE_EVENT""")
PY

Repository: openshift/console-operator

Length of output: 164


Scope lastAppliedDeploymentGeneration to the Deployment UID.

If the Deployment is recreated, its new UID can have a lower generation than the cached value. resourceapply.ApplyDeployment then performs an unnecessary update and emits DeploymentUpdated. Store the UID with the generation, or reset the cached generation when the UID changes.

🤖 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 `@pkg/console/operator/sync_v400.go` around lines 334 - 343, Scope
co.lastAppliedDeploymentGeneration to the current Deployment UID in the
expected-generation logic. Track the UID alongside the cached generation or
reset the cached generation whenever the Deployment UID changes, ensuring a
recreated Deployment cannot reuse a stale higher generation before
resourceapply.ApplyDeployment.

Source: MCP tools

var deployment *appsv1.Deployment
applyDepErr := controllersutil.RetryOnTransientError(func() error {
var e error
Expand All @@ -339,14 +349,15 @@ func (co *consoleOperator) SyncDeployment(
co.deploymentClient,
recorder,
requiredDeployment,
resourcemerge.ExpectedDeploymentGeneration(requiredDeployment, updatedOperatorConfig.Status.Generations),
expectedGen,
)
return e
})

if applyDepErr != nil {
return nil, "FailedApply", applyDepErr
}
co.lastAppliedDeploymentGeneration = deployment.Generation
return deployment, "", nil
}

Expand Down
170 changes: 170 additions & 0 deletions pkg/console/operator/sync_v400_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package operator
import (
"context"
"encoding/json"
"fmt"
"sort"
"testing"
"time"
Expand All @@ -12,12 +13,19 @@ import (
configv1 "github.com/openshift/api/config/v1"
operatorv1 "github.com/openshift/api/operator/v1"
configlistersv1 "github.com/openshift/client-go/config/listers/config/v1"
"github.com/openshift/library-go/pkg/operator/events"
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
kubefake "k8s.io/client-go/kubernetes/fake"
appsv1listers "k8s.io/client-go/listers/apps/v1"
corev1listers "k8s.io/client-go/listers/core/v1"
clienttesting "k8s.io/client-go/testing"
"k8s.io/client-go/tools/cache"
clocktesting "k8s.io/utils/clock/testing"

"github.com/openshift/console-operator/pkg/api"
"github.com/openshift/console-operator/pkg/console/telemetry"
Expand Down Expand Up @@ -679,3 +687,165 @@ func TestEvaluateDeploymentAvailability(t *testing.T) {
}
})
}

// syncDeploymentInputs holds the minimal inputs needed to call SyncDeployment.
type syncDeploymentInputs struct {
operatorConfig *operatorv1.Console
cm *v1.ConfigMap
serviceCAConfigMap *v1.ConfigMap
oauthServingCertCM *v1.ConfigMap
authServerCACM *v1.ConfigMap
trustedCACM *v1.ConfigMap
oauthSecret *v1.Secret
sessionSecret *v1.Secret
servingCertSecret *v1.Secret
proxyConfig *configv1.Proxy
infrastructureConfig *configv1.Infrastructure
}

func newSyncDeploymentInputs() syncDeploymentInputs {
return syncDeploymentInputs{
operatorConfig: &operatorv1.Console{
ObjectMeta: metav1.ObjectMeta{Name: "cluster", UID: "test-uid"},
Spec: operatorv1.ConsoleSpec{},
},
cm: &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "console-config", Namespace: "openshift-console", ResourceVersion: "100"}},
serviceCAConfigMap: &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "service-ca", Namespace: "openshift-console", ResourceVersion: "200"}},
oauthServingCertCM: &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "oauth-serving-cert", Namespace: "openshift-console", ResourceVersion: "300"}},
authServerCACM: nil,
trustedCACM: &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "trusted-ca", Namespace: "openshift-console", ResourceVersion: "400"}},
oauthSecret: &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "console-oauth-config", Namespace: "openshift-console", ResourceVersion: "500"}},
sessionSecret: nil,
servingCertSecret: &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "console-serving-cert", Namespace: "openshift-console", ResourceVersion: "600"}},
proxyConfig: &configv1.Proxy{ObjectMeta: metav1.ObjectMeta{Name: "cluster", ResourceVersion: "700"}},
infrastructureConfig: &configv1.Infrastructure{
ObjectMeta: metav1.ObjectMeta{Name: "cluster", ResourceVersion: "800"},
Status: configv1.InfrastructureStatus{
ControlPlaneTopology: configv1.HighlyAvailableTopologyMode,
},
},
}
}

func (in syncDeploymentInputs) callSyncDeployment(co *consoleOperator, recorder events.Recorder) (*appsv1.Deployment, string, error) {
return co.SyncDeployment(
context.Background(),
in.operatorConfig,
in.cm,
in.serviceCAConfigMap,
in.oauthServingCertCM,
in.authServerCACM,
in.trustedCACM,
in.oauthSecret,
in.sessionSecret,
in.servingCertSecret,
in.proxyConfig,
in.infrastructureConfig,
recorder,
)
}

func TestSyncDeploymentGenerationCache(t *testing.T) {
newRecorder := func() events.Recorder {
return events.NewInMemoryRecorder("test", clocktesting.NewFakePassiveClock(time.Now()))
}

t.Run("first apply caches generation", func(t *testing.T) {
fakeClient := kubefake.NewSimpleClientset()
co := &consoleOperator{
deploymentClient: fakeClient.AppsV1(),
}
inputs := newSyncDeploymentInputs()

dep, _, err := inputs.callSyncDeployment(co, newRecorder())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if co.lastAppliedDeploymentGeneration != dep.Generation {
t.Errorf("expected cache=%d, got=%d", dep.Generation, co.lastAppliedDeploymentGeneration)
}
})

t.Run("cached generation prevents echo update", func(t *testing.T) {
fakeClient := kubefake.NewSimpleClientset()
co := &consoleOperator{
deploymentClient: fakeClient.AppsV1(),
}
inputs := newSyncDeploymentInputs()
recorder := newRecorder()

// First apply creates the deployment and populates all annotations
// including the specHash.
_, _, err := inputs.callSyncDeployment(co, recorder)
if err != nil {
t.Fatalf("first apply: %v", err)
}

// Simulate the API server incrementing generation after the update.
existing, _ := fakeClient.AppsV1().Deployments("openshift-console").Get(context.Background(), "console", metav1.GetOptions{})
existing.Generation = 10
Comment on lines +785 to +786

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Check the deployment lookup error before using existing.

Line 785 discards the Get error. If the fake client returns an error, line 786 can dereference a nil deployment and hide the actual test failure.

Proposed fix
-		existing, _ := fakeClient.AppsV1().Deployments("openshift-console").Get(context.Background(), "console", metav1.GetOptions{})
+		existing, err := fakeClient.AppsV1().Deployments("openshift-console").Get(context.Background(), "console", metav1.GetOptions{})
+		if err != nil {
+			t.Fatalf("get deployment for generation bump: %v", err)
+		}
 		existing.Generation = 10

As per coding guidelines, **/*_test.go: “In Go tests, do not ignore returned errors.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
existing, _ := fakeClient.AppsV1().Deployments("openshift-console").Get(context.Background(), "console", metav1.GetOptions{})
existing.Generation = 10
existing, err := fakeClient.AppsV1().Deployments("openshift-console").Get(context.Background(), "console", metav1.GetOptions{})
if err != nil {
t.Fatalf("get deployment for generation bump: %v", err)
}
existing.Generation = 10
🤖 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 `@pkg/console/operator/sync_v400_test.go` around lines 785 - 786, Update the
deployment lookup in the test around existing to capture and assert or fail on
the Get error before accessing existing.Generation. Preserve the subsequent
generation setup only after confirming the deployment was retrieved
successfully.

Source: Coding guidelines

_, err = fakeClient.AppsV1().Deployments("openshift-console").Update(context.Background(), existing, metav1.UpdateOptions{})
if err != nil {
t.Fatalf("simulating generation bump: %v", err)
}

// Set cache to match (as if previous SyncDeployment returned this).
co.lastAppliedDeploymentGeneration = 10

// Simulate stale informer: operator status still has gen 9.
inputs.operatorConfig = inputs.operatorConfig.DeepCopy()
inputs.operatorConfig.Status.Generations = []operatorv1.GenerationStatus{{
Group: "apps",
Resource: "deployments",
Namespace: "openshift-console",
Name: "console",
LastGeneration: 9,
}}

// Track whether ApplyDeployment calls Update.
updateCalls := 0
fakeClient.PrependReactor("update", "deployments", func(action clienttesting.Action) (bool, runtime.Object, error) {
updateCalls++
return false, nil, nil
})

// Second apply with identical inputs — cache should bridge the gap.
_, _, err = inputs.callSyncDeployment(co, recorder)
if err != nil {
t.Fatalf("second apply: %v", err)
}
if updateCalls != 0 {
t.Errorf("expected no deployment update (echo prevented), got %d update(s)", updateCalls)
}
if co.lastAppliedDeploymentGeneration != 10 {
t.Errorf("expected cache to remain 10, got %d", co.lastAppliedDeploymentGeneration)
}
})

t.Run("failed apply preserves cached generation", func(t *testing.T) {
fakeClient := kubefake.NewSimpleClientset()
co := &consoleOperator{
deploymentClient: fakeClient.AppsV1(),
lastAppliedDeploymentGeneration: 5,
}
inputs := newSyncDeploymentInputs()

// Make Get return a non-retryable error so RetryOnTransientError
// gives up immediately.
fakeClient.PrependReactor("get", "deployments", func(action clienttesting.Action) (bool, runtime.Object, error) {
return true, nil, apierrors.NewForbidden(
schema.GroupResource{Group: "apps", Resource: "deployments"},
"console",
fmt.Errorf("synthetic test error"),
)
})

_, _, err := inputs.callSyncDeployment(co, newRecorder())
if err == nil {
t.Fatal("expected error, got nil")
}
if co.lastAppliedDeploymentGeneration != 5 {
t.Errorf("expected cache to remain 5 after failure, got %d", co.lastAppliedDeploymentGeneration)
}
})
}