Skip to content
Merged
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
35 changes: 33 additions & 2 deletions e2e/common/cli/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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)
})
})
}
37 changes: 34 additions & 3 deletions pkg/trait/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down
79 changes: 79 additions & 0 deletions pkg/trait/gc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
Loading