Skip to content

Commit c96747f

Browse files
Merge pull request #2159 from rabi/ttl-after-finished
Collect dataplane AnsibleEE jobs only when deployment has finished
2 parents 266396c + 16ca052 commit c96747f

8 files changed

Lines changed: 356 additions & 9 deletions

File tree

‎api/bases/dataplane.openstack.org_openstackdataplanedeployments.yaml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ spec:
105105
preserveJobs:
106106
default: true
107107
description: |-
108-
PreserveJobs - do not delete jobs after they finished e.g. to check logs
108+
PreserveJobs - do not delete jobs after they finished e.g. to check logs.
109+
When false the jobs are deleted after a delay once the deployment is
110+
finished - deployed, or failed with the backoff limit exceeded - and not
111+
when an individual job finishes. While a deployment is in flight its
112+
jobs are what tells the operator which services already ran.
109113
PreserveJobs default: true
110114
enum:
111115
- true

‎api/dataplane/v1beta1/openstackdataplanedeployment_types.go‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ type OpenStackDataPlaneDeploymentSpec struct {
3939
// +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:number"}
4040
BackoffLimit *int32 `json:"backoffLimit,omitempty"`
4141

42-
// PreserveJobs - do not delete jobs after they finished e.g. to check logs
42+
// PreserveJobs - do not delete jobs after they finished e.g. to check logs.
43+
// When false the jobs are deleted after a delay once the deployment is
44+
// finished - deployed, or failed with the backoff limit exceeded - and not
45+
// when an individual job finishes. While a deployment is in flight its
46+
// jobs are what tells the operator which services already ran.
4347
// PreserveJobs default: true
4448
// +kubebuilder:validation:Enum:=true;false
4549
// +kubebuilder:default:=true
@@ -189,6 +193,42 @@ func (instance OpenStackDataPlaneDeployment) IsReady() bool {
189193
return instance.Status.Conditions.IsTrue(condition.ReadyCondition)
190194
}
191195

196+
// IsFinished reports whether the deployment reached a state it will not advance
197+
// from. The failure verdict is only readable before InitConditions resets the
198+
// conditions.
199+
func (instance *OpenStackDataPlaneDeployment) IsFinished() bool {
200+
return instance.Status.Deployed || instance.isTerminalFailure()
201+
}
202+
203+
// FinishedAt reports when the deployment reached that state, or nil while it can
204+
// still progress. LastTransitionTime only moves when the condition state
205+
// changes, so the value is stable across reconciles.
206+
func (instance *OpenStackDataPlaneDeployment) FinishedAt() *metav1.Time {
207+
if instance.Status.Conditions == nil {
208+
return nil
209+
}
210+
deploymentCondition := instance.Status.Conditions.Get(condition.DeploymentReadyCondition)
211+
if deploymentCondition == nil || deploymentCondition.LastTransitionTime.IsZero() {
212+
return nil
213+
}
214+
if !instance.Status.Deployed && !instance.isTerminalFailure() {
215+
return nil
216+
}
217+
218+
finishedAt := deploymentCondition.LastTransitionTime
219+
return &finishedAt
220+
}
221+
222+
func (instance *OpenStackDataPlaneDeployment) isTerminalFailure() bool {
223+
if instance.Status.Conditions == nil {
224+
return false
225+
}
226+
deploymentCondition := instance.Status.Conditions.Get(condition.DeploymentReadyCondition)
227+
return deploymentCondition != nil &&
228+
deploymentCondition.Severity == condition.SeverityError &&
229+
deploymentCondition.Reason == condition.JobReasonBackoffLimitExceeded
230+
}
231+
192232
// InitConditions - Initializes Status Conditons
193233
func (instance *OpenStackDataPlaneDeployment) InitConditions() {
194234
instance.Status.Conditions = condition.Conditions{}

‎bindata/crds/crds.yaml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20732,7 +20732,11 @@ spec:
2073220732
preserveJobs:
2073320733
default: true
2073420734
description: |-
20735-
PreserveJobs - do not delete jobs after they finished e.g. to check logs
20735+
PreserveJobs - do not delete jobs after they finished e.g. to check logs.
20736+
When false the jobs are deleted after a delay once the deployment is
20737+
finished - deployed, or failed with the backoff limit exceeded - and not
20738+
when an individual job finishes. While a deployment is in flight its
20739+
jobs are what tells the operator which services already ran.
2073620740
PreserveJobs default: true
2073720741
enum:
2073820742
- true

‎config/crd/bases/dataplane.openstack.org_openstackdataplanedeployments.yaml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ spec:
105105
preserveJobs:
106106
default: true
107107
description: |-
108-
PreserveJobs - do not delete jobs after they finished e.g. to check logs
108+
PreserveJobs - do not delete jobs after they finished e.g. to check logs.
109+
When false the jobs are deleted after a delay once the deployment is
110+
finished - deployed, or failed with the backoff limit exceeded - and not
111+
when an individual job finishes. While a deployment is in flight its
112+
jobs are what tells the operator which services already ran.
109113
PreserveJobs default: true
110114
enum:
111115
- true

‎docs/assemblies/dataplane_resources.adoc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ OpenStackDataPlaneDeploymentSpec defines the desired state of OpenStackDataPlane
9393
| false
9494
9595
| preserveJobs
96-
| PreserveJobs - do not delete jobs after they finished e.g. to check logs PreserveJobs default: true
96+
| PreserveJobs - do not delete jobs after they finished e.g. to check logs. When false the jobs are deleted after a delay once the deployment is finished - deployed, or failed with the backoff limit exceeded - and not when an individual job finishes. While a deployment is in flight its jobs are what tells the operator which services already ran. PreserveJobs default: true
9797
| bool
9898
| false
9999

‎internal/controller/dataplane/openstackdataplanedeployment_controller.go‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
551664
func (r *OpenStackDataPlaneDeploymentReconciler) GetService(
552665
ctx context.Context,

‎internal/dataplane/util/ansible_execution.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,13 @@ func ParseAnsibleExecutionSummaryFromPod(
247247
return nil, nil
248248
}
249249

250+
// Labels the AnsibleEE Jobs carry, to build the Job spec and find them again.
251+
const (
252+
AnsibleEEServiceLabel = "openstackdataplaneservice"
253+
AnsibleEEDeploymentLabel = "openstackdataplanedeployment"
254+
AnsibleEENodeSetLabel = "openstackdataplanenodeset"
255+
)
256+
250257
// GetAnsibleExecutionNameAndLabels Name and Labels of AnsibleEE
251258
func GetAnsibleExecutionNameAndLabels(service *dataplanev1.OpenStackDataPlaneService,
252259
deploymentName string,
@@ -267,7 +274,7 @@ func GetAnsibleExecutionNameAndLabels(service *dataplanev1.OpenStackDataPlaneSer
267274
}
268275

269276
labels := GetGlobalAnsibleExecutionLabels(service.Name, deploymentName)
270-
labels["openstackdataplanenodeset"] = nodeSetName
277+
labels[AnsibleEENodeSetLabel] = nodeSetName
271278
return executionName, labels
272279
}
273280

@@ -276,8 +283,8 @@ func GetAnsibleExecutionNameAndLabels(service *dataplanev1.OpenStackDataPlaneSer
276283
// selector must not carry the nodeset label.
277284
func GetGlobalAnsibleExecutionLabels(serviceName string, deploymentName string) map[string]string {
278285
return map[string]string{
279-
"openstackdataplaneservice": serviceName,
280-
"openstackdataplanedeployment": deploymentName,
286+
AnsibleEEServiceLabel: serviceName,
287+
AnsibleEEDeploymentLabel: deploymentName,
281288
}
282289
}
283290

@@ -306,7 +313,9 @@ func (a *EEJob) BuildAeeJobSpec(
306313
}
307314

308315
a.BackoffLimit = deployment.Spec.BackoffLimit
309-
a.PreserveJobs = deployment.Spec.PreserveJobs
316+
// Cleanup must not start while the deployment still runs: lib-common never
317+
// recreates a Job whose hash is unchanged.
318+
a.PreserveJobs = deployment.Spec.PreserveJobs || !deployment.IsFinished()
310319
a.FormatAEECmdLineArguments(aeeSpec)
311320
a.FormatAEEExtraVars(aeeSpec, service, deployment, nodeSet)
312321
a.DetermineAeeImage(aeeSpec)

0 commit comments

Comments
 (0)