This is an automated email from the ASF dual-hosted git repository.

pcongiusti pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-k.git


The following commit(s) were added to refs/heads/main by this push:
     new c8c513a86 fix(gc): Prevent false negative in dry-build detection with 
replicas=0
c8c513a86 is described below

commit c8c513a86134623da5a813c59dc66de2970a648e
Author: Pranjul Kalsi <[email protected]>
AuthorDate: Mon Dec 15 22:15:39 2025 +0530

    fix(gc): Prevent false negative in dry-build detection with replicas=0
---
 e2e/common/cli/deploy_test.go | 35 +++++++++++++++++--
 pkg/trait/gc.go               | 37 ++++++++++++++++++--
 pkg/trait/gc_test.go          | 79 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 146 insertions(+), 5 deletions(-)

diff --git a/e2e/common/cli/deploy_test.go b/e2e/common/cli/deploy_test.go
index 49081bc20..6ed7be58a 100644
--- a/e2e/common/cli/deploy_test.go
+++ b/e2e/common/cli/deploy_test.go
@@ -104,7 +104,6 @@ func TestPipeBuildDontRun(t *testing.T) {
                                WithTransform(PipeConditionReason, 
Equal("BuildComplete")))
                })
                t.Run("deploy the pipe", func(t *testing.T) {
-                       t.Skip("Skipping: deploy/undeploy pipe lifecycle needs 
further investigation")
                        g.Expect(Kamel(t, ctx, "deploy", name, "-n", 
ns).Execute()).To(Succeed())
                        g.Eventually(IntegrationPhase(t, ctx, ns, name), 
TestTimeoutMedium).Should(Equal(v1.IntegrationPhaseRunning))
                        g.Eventually(PipePhase(t, ctx, ns, name), 
TestTimeoutMedium).Should(Equal(v1.PipePhaseReady))
@@ -115,7 +114,6 @@ func TestPipeBuildDontRun(t *testing.T) {
                        g.Eventually(IntegrationLogs(t, ctx, ns, name), 
TestTimeoutMedium).Should(ContainSubstring("HelloPipe"))
                })
                t.Run("undeploy the pipe", func(t *testing.T) {
-                       t.Skip("Skipping: deploy/undeploy pipe lifecycle needs 
further investigation")
                        g.Expect(Kamel(t, ctx, "undeploy", name, "-n", 
ns).Execute()).To(Succeed())
                        g.Eventually(IntegrationPhase(t, ctx, ns, name), 
TestTimeoutMedium).Should(Equal(v1.IntegrationPhaseBuildComplete))
                        g.Eventually(PipePhase(t, ctx, ns, name), 
TestTimeoutMedium).Should(Equal(v1.PipePhaseBuildComplete))
@@ -124,3 +122,36 @@ func TestPipeBuildDontRun(t *testing.T) {
                })
        })
 }
+
+func TestHasNeverDeployedLogic(t *testing.T) {
+       t.Parallel()
+       WithNewTestNamespace(t, func(ctx context.Context, g *WithT, ns string) {
+               // Critical edge case: deployment with trait config (e.g. 
replicas=0)
+               t.Run("deployment with trait config sets deployment timestamp", 
func(t *testing.T) {
+                       deployedName := RandomizedSuffixName("deployed")
+                       g.Expect(KamelRun(t, ctx, ns, "files/yaml.yaml",
+                               "--name", deployedName,
+                               "--trait", "deployment.replicas=0",
+                       ).Execute()).To(Succeed())
+
+                       g.Eventually(func() v1.IntegrationPhase {
+                               it := Integration(t, ctx, ns, deployedName)()
+                               if it == nil {
+                                       return ""
+                               }
+                               return it.Status.Phase
+                       }, TestTimeoutMedium).Should(Or(
+                               Equal(v1.IntegrationPhaseDeploying),
+                               Equal(v1.IntegrationPhaseRunning),
+                       ))
+
+                       it := Integration(t, ctx, ns, deployedName)()
+                       g.Expect(it).NotTo(BeNil())
+                       g.Expect(it.Status.DeploymentTimestamp).NotTo(BeNil(),
+                               "ANY deployment (regardless of replica count) 
MUST set DeploymentTimestamp - hasNeverDeployed returns false")
+
+                       t.Logf("Integration deployed with spec.replicas=%v, 
DeploymentTimestamp=%v",
+                               it.Spec.Replicas, it.Status.DeploymentTimestamp)
+               })
+       })
+}
diff --git a/pkg/trait/gc.go b/pkg/trait/gc.go
index 7520cca9e..d3d8f39c2 100644
--- a/pkg/trait/gc.go
+++ b/pkg/trait/gc.go
@@ -119,7 +119,19 @@ func (t *gcTrait) Configure(e *Environment) (bool, 
*TraitCondition, error) {
 }
 
 func (t *gcTrait) Apply(e *Environment) error {
-       if e.Integration.GetGeneration() > 1 || 
e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete) {
+       // Garbage collection runs when:
+       // 1. Generation > 1: resource was updated, clean up old generation 
resources
+       // 2. BuildComplete phase AND integration has previously been deployed: 
undeploy scenario
+       shouldRunGC := e.Integration.GetGeneration() > 1
+
+       if !shouldRunGC && 
e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete) {
+               // Only run GC if integration was previously deployed (undeploy 
case)
+               if !hasNeverDeployed(e.Integration) {
+                       shouldRunGC = true
+               }
+       }
+
+       if shouldRunGC {
                // Register a post action that deletes the existing resources 
that are labelled
                // with the previous integration generation(s).
                // We make the assumption generation is a monotonically 
increasing strictly positive integer,
@@ -190,8 +202,10 @@ func (t *gcTrait) garbageCollectResources(e *Environment) 
error {
        selector := labels.NewSelector().
                Add(*integration)
 
-       // Skip the generation checking when we undeploy (which requires 
therefore to remove all dependent resources)
-       if !e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete) {
+       // On undeploy, delete all resources regardless of generation.
+       // On generation upgrade, filter to only delete old resources.
+       isUndeploying := e.IntegrationInPhase(v1.IntegrationPhaseBuildComplete) 
&& !hasNeverDeployed(e.Integration)
+       if !isUndeploying {
                selector = selector.Add(*generation)
        }
 
@@ -251,6 +265,23 @@ func canBeDeleted(it *v1.Integration, u 
unstructured.Unstructured) bool {
        return false
 }
 
+// hasNeverDeployed returns true if the integration has never been deployed.
+// Checks both DeploymentTimestamp and Ready condition for reliability.
+func hasNeverDeployed(integration *v1.Integration) bool {
+       // Primary check: DeploymentTimestamp is set when deployment is 
triggered
+       if integration.Status.DeploymentTimestamp != nil && 
!integration.Status.DeploymentTimestamp.IsZero() {
+               return false // has been deployed
+       }
+
+       // Secondary check: Ready condition becomes true only after successful 
deployment
+       readyCond := 
integration.Status.GetCondition(v1.IntegrationConditionReady)
+       if readyCond != nil && readyCond.FirstTruthyTime != nil && 
!readyCond.FirstTruthyTime.IsZero() {
+               return false
+       }
+
+       return true
+}
+
 // getDeletableTypes returns the list of deletable types resources, inspecting 
the rules for which the operator SA is allowed in the
 // Integration namespace.
 func (t *gcTrait) getDeletableTypes(e *Environment) 
(map[schema.GroupVersionKind]struct{}, error) {
diff --git a/pkg/trait/gc_test.go b/pkg/trait/gc_test.go
index 40d77f1b4..f81d216f6 100644
--- a/pkg/trait/gc_test.go
+++ b/pkg/trait/gc_test.go
@@ -164,6 +164,10 @@ func TestGarbageCollectUndeploying(t *testing.T) {
        gcTrait, environment := createNominalGCTest()
        environment.Integration.Status.Phase = v1.IntegrationPhaseBuildComplete
 
+       // Simulate undeploy scenario: DeploymentTimestamp set means 
integration was previously deployed
+       now := metav1.Now()
+       environment.Integration.Status.DeploymentTimestamp = &now
+
        deployment := getIntegrationDeployment(environment.Integration)
        gcTrait.Client, _ = internal.NewFakeClient(deployment)
 
@@ -388,3 +392,78 @@ func TestCanResourceBeDeleted(t *testing.T) {
        )
        assert.True(t, canBeDeleted(it, resThisItOwner))
 }
+
+func TestHasNeverDeployed(t *testing.T) {
+       tests := []struct {
+               name                string
+               deploymentTimestamp *metav1.Time
+               readyCondition      *v1.IntegrationCondition
+               expected            bool
+       }{
+               {
+                       name:                "never deployed - both checks nil",
+                       deploymentTimestamp: nil,
+                       readyCondition:      nil,
+                       expected:            true,
+               },
+               {
+                       name:                "deployed - DeploymentTimestamp 
set",
+                       deploymentTimestamp: ptr.To(metav1.Now()),
+                       readyCondition:      nil,
+                       expected:            false,
+               },
+               {
+                       name:                "deployed - Ready FirstTruthyTime 
set",
+                       deploymentTimestamp: nil,
+                       readyCondition: &v1.IntegrationCondition{
+                               Type:            v1.IntegrationConditionReady,
+                               Status:          corev1.ConditionTrue,
+                               FirstTruthyTime: ptr.To(metav1.Now()),
+                       },
+                       expected: false,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       integration := &v1.Integration{
+                               Status: v1.IntegrationStatus{
+                                       DeploymentTimestamp: 
tt.deploymentTimestamp,
+                               },
+                       }
+                       if tt.readyCondition != nil {
+                               integration.Status.Conditions = 
[]v1.IntegrationCondition{*tt.readyCondition}
+                       }
+
+                       result := hasNeverDeployed(integration)
+                       assert.Equal(t, tt.expected, result)
+               })
+       }
+}
+
+func TestGarbageCollectDryBuildSkipsGC(t *testing.T) {
+       gcTrait, environment := createNominalGCTest()
+       environment.Integration.Status.Phase = v1.IntegrationPhaseBuildComplete
+
+       environment.Integration.Status.DeploymentTimestamp = nil
+       environment.Integration.Status.Conditions = nil
+
+       deployment := getIntegrationDeployment(environment.Integration)
+       gcTrait.Client, _ = internal.NewFakeClient(deployment)
+
+       environment.Client = gcTrait.Client
+       resourceDeleted := false
+       fakeClient := gcTrait.Client.(*internal.FakeClient)
+       fakeClient.Intercept(&interceptor.Funcs{
+               Delete: func(ctx context.Context, client ctrl.WithWatch, obj 
ctrl.Object, opts ...ctrl.DeleteOption) error {
+                       resourceDeleted = true
+                       return nil
+               },
+       })
+
+       err := gcTrait.Apply(environment)
+
+       require.NoError(t, err)
+       assert.Len(t, environment.PostActions, 0)
+       assert.False(t, resourceDeleted)
+}

Reply via email to