@@ -21,6 +21,7 @@ import (
2121 "context"
2222 "errors"
2323 "fmt"
24+ "math"
2425 "slices"
2526 "time"
2627
@@ -29,6 +30,7 @@ import (
2930 batchv1 "k8s.io/api/batch/v1"
3031 corev1 "k8s.io/api/core/v1"
3132 k8s_errors "k8s.io/apimachinery/pkg/api/errors"
33+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3234 "k8s.io/apimachinery/pkg/runtime"
3335 "k8s.io/apimachinery/pkg/types"
3436 "k8s.io/client-go/kubernetes"
@@ -107,12 +109,30 @@ func (r *OpenStackDataPlaneDeploymentReconciler) Reconcile(ctx context.Context,
107109 Log ,
108110 )
109111
112+ // Whether Job cleanup may be scheduled: a verdict freshly computed by this
113+ // reconcile is only trustworthy once the status patch below persists it.
114+ finishedOnEntry := instance .IsFinished ()
115+ statusPersisted := false
116+
117+ // Registered ahead of the status patch defer so it runs after the verdict persists.
118+ defer func () {
119+ if err := r .reconcileJobTTL (ctx , helper , instance , finishedOnEntry || statusPersisted ); err != nil {
120+ Log .Error (err , "Failed to reconcile AnsibleEE job cleanup" , "deployment" , instance .Name )
121+ if _err == nil {
122+ _err = err
123+ }
124+ }
125+ }()
126+
110127 // If the deploy is already done, return immediately.
111128 if instance .Status .Deployed {
112129 Log .Info ("Already deployed" , "instance.Status.Deployed" , instance .Status .Deployed )
113130 return ctrl.Result {}, nil
114131 }
115132
133+ // If the deployment is in a terminal state, do not reconcile even if
134+ // nodesets or other watched resources change. The deployment should not
135+ // be retried.
116136 if terminalFailure (instance ) {
117137 Log .Info ("Deployment is terminal, skipping reconciliation" ,
118138 "deployment" , instance .Name )
@@ -162,6 +182,7 @@ func (r *OpenStackDataPlaneDeploymentReconciler) Reconcile(ctx context.Context,
162182 _err = err
163183 return
164184 }
185+ statusPersisted = true
165186 }()
166187
167188 // Ensure NodeSets
@@ -547,6 +568,98 @@ func (r *OpenStackDataPlaneDeploymentReconciler) deployGlobalServices(
547568 return nil
548569}
549570
571+ // Mirrors the delay lib-common applies to finished Jobs.
572+ const jobCleanupDelaySeconds int32 = 10 * 60
573+
574+ // reconcileJobTTL defers Job cleanup until the deployment is finished: while it
575+ // runs the Jobs are the only record of which services already ran. scheduleCleanup
576+ // is false while the terminal verdict exists only in memory.
577+ func (r * OpenStackDataPlaneDeploymentReconciler ) reconcileJobTTL (
578+ ctx context.Context ,
579+ helper * helper.Helper ,
580+ instance * dataplanev1.OpenStackDataPlaneDeployment ,
581+ scheduleCleanup bool ,
582+ ) error {
583+ if instance .Spec .PreserveJobs {
584+ return nil
585+ }
586+
587+ jobList := & batchv1.JobList {}
588+ err := helper .GetClient ().List (
589+ ctx ,
590+ jobList ,
591+ client .InNamespace (instance .Namespace ),
592+ client.MatchingLabels {dataplaneutil .AnsibleEEDeploymentLabel : instance .Name },
593+ )
594+ if err != nil {
595+ return err
596+ }
597+
598+ finished := scheduleCleanup && instance .IsFinished ()
599+ finishedAt := instance .FinishedAt ()
600+
601+ for i := range jobList .Items {
602+ job := & jobList .Items [i ]
603+ if finished {
604+ // Ageing starts at the Job's own completion time, so one schedule per Job
605+ // is enough and the value must not be re-derived afterwards.
606+ if job .Spec .TTLSecondsAfterFinished != nil {
607+ continue
608+ }
609+ patch := client .MergeFrom (job .DeepCopy ())
610+ ttl := jobCleanupDelay (job , finishedAt )
611+ job .Spec .TTLSecondsAfterFinished = & ttl
612+
613+ err := helper .GetClient ().Patch (ctx , job , patch )
614+ if err != nil && ! k8s_errors .IsNotFound (err ) {
615+ return err
616+ }
617+ continue
618+ }
619+
620+ // An older operator may have left a deadline on a running deployment.
621+ if job .Spec .TTLSecondsAfterFinished == nil {
622+ continue
623+ }
624+ patch := client .MergeFrom (job .DeepCopy ())
625+ job .Spec .TTLSecondsAfterFinished = nil
626+
627+ err := helper .GetClient ().Patch (ctx , job , patch )
628+ if err != nil && ! k8s_errors .IsNotFound (err ) {
629+ return err
630+ }
631+ }
632+ return nil
633+ }
634+
635+ // jobCleanupDelay returns the TTLSecondsAfterFinished that makes the Job expire
636+ // jobCleanupDelaySeconds after the deployment finished. Ageing starts at the
637+ // Job's own completion time, so a Job that finished while the deployment still
638+ // ran gets that elapsed time added back - a flat delay would delete it at once.
639+ func jobCleanupDelay (job * batchv1.Job , finishedAt * metav1.Time ) int32 {
640+ finishTime := jobFinishTime (job )
641+ if finishedAt == nil || finishTime == nil || ! finishedAt .After (finishTime .Time ) {
642+ return jobCleanupDelaySeconds
643+ }
644+
645+ delay := int64 (jobCleanupDelaySeconds ) + int64 (math .Floor (finishedAt .Time .Sub (finishTime .Time ).Seconds ()))
646+ return int32 (min (delay , math .MaxInt32 ))
647+ }
648+
649+ // jobFinishTime mirrors the TTL-after-finished controller, which measures from
650+ // the Complete/Failed transition rather than CompletionTime.
651+ func jobFinishTime (job * batchv1.Job ) * metav1.Time {
652+ for _ , jobCondition := range job .Status .Conditions {
653+ isFinished := (jobCondition .Type == batchv1 .JobComplete || jobCondition .Type == batchv1 .JobFailed ) &&
654+ jobCondition .Status == corev1 .ConditionTrue
655+ if isFinished && ! jobCondition .LastTransitionTime .IsZero () {
656+ finishTime := jobCondition .LastTransitionTime
657+ return & finishTime
658+ }
659+ }
660+ return job .Status .CompletionTime
661+ }
662+
550663// GetService retrieves a service for the OpenStackDataPlaneDeployment
551664func (r * OpenStackDataPlaneDeploymentReconciler ) GetService (
552665 ctx context.Context ,
0 commit comments