Repository navigation
fix(operator): do not record a config Job that ran on replaced ConfigMap data #810
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
271616d
3048e19
fb37fe0
90a6420
03e975f
462ff47
9bf2984
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,8 @@ package controller | |
|
|
||
| import ( | ||
| "context" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "fmt" | ||
| "slices" | ||
| "sort" | ||
|
|
@@ -74,6 +76,11 @@ const ( | |
| // interrupt Job created before the bound existed, is never ended by it. | ||
| annotationRestartAllowance = v1alpha1.METADATA_PREFIX + "/restart-allowance" | ||
|
|
||
| // annotationConfigHash is stamped at creation on a config-stage Job: a hash of the package | ||
| // ConfigMap data the pod mounts. The pod reads each key once at start, so a Job that ran | ||
| // against data since replaced must not have its completion recorded as the new data's. | ||
| annotationConfigHash = v1alpha1.METADATA_PREFIX + "/config-hash" | ||
|
|
||
| // interruptRestartLimitAction is the event action for an interrupt Job ended that way. | ||
| interruptRestartLimitAction = "RestartLimitExceeded" | ||
|
|
||
|
|
@@ -126,7 +133,9 @@ const ( | |
| type JobReconciler struct { | ||
| client.Client | ||
| // uncached reads straight from the apiserver: a Node re-read after a patch conflict (see | ||
| // patchNodeState), and a terminal Job's child pods (see packageJobRestarts). | ||
| // patchNodeState), a terminal Job's child pods (see packageJobRestarts), and the package | ||
| // ConfigMap behind the config-hash check (see configSuperseded), where a stale cache would | ||
| // match the old hash. | ||
| uncached client.Reader | ||
| recorder events.EventRecorder | ||
| opts JobOperatorOptions | ||
|
|
@@ -285,13 +294,57 @@ func (r *JobReconciler) handleCompleteJob(ctx context.Context, job *batchv1.Job) | |
| return ctrl.Result{}, r.markJobProcessed(ctx, job, r.opts.JobTTLSucceeded) | ||
| } | ||
|
|
||
| // The entry is back at config after a config-update reset, which looks the same as the run this | ||
| // Job did, so recording would complete the node on data it never saw. Drop the Job instead; | ||
| // the stage is re-created from the entry against the current ConfigMap. | ||
| if superseded, err := r.configSuperseded(ctx, job, pkg); err != nil { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The remaining window is wider than the description says. For a node already at ( Question: could the reset write reset entries unconditionally (e.g. an uncached
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right that the window starts at the pass's node read, and that the reset writes nothing for a node already at (config, in_progress). That is what |
||
| return ctrl.Result{}, err | ||
| } else if superseded { | ||
| return ctrl.Result{}, deleteJobForeground(ctx, r.Client, job, "config changed while the config stage ran") | ||
| } | ||
|
|
||
| if err := r.recordJobCompletion(ctx, job, pkg); err != nil { | ||
| return ctrl.Result{}, err | ||
| } | ||
|
|
||
| return ctrl.Result{}, r.markJobProcessed(ctx, job, r.opts.JobTTLSucceeded) | ||
| } | ||
|
|
||
| // configSuperseded reports a config-stage Job whose stamped ConfigMap hash no longer matches the | ||
| // live ConfigMap. A Job without the stamp (created by an older operator) or a ConfigMap that is | ||
| // gone is never superseded, so those still record as before. | ||
| func (r *JobReconciler) configSuperseded(ctx context.Context, job *batchv1.Job, pkg *PackageSkyhook) (bool, error) { | ||
| stamped, ok := job.Annotations[annotationConfigHash] | ||
| if !ok || pkg.Stage != v1alpha1.StageConfig { | ||
| return false, nil | ||
| } | ||
| cm := &corev1.ConfigMap{} | ||
| key := types.NamespacedName{Namespace: job.Namespace, Name: packageConfigMapName(pkg.Skyhook, pkg.Name, pkg.Version)} | ||
| // uncached: a stale cache could match the old hash and let a superseded completion record | ||
| if err := uncachedReader(r.uncached, r.Client).Get(ctx, key, cm); err != nil { | ||
| if apierrors.IsNotFound(err) { | ||
| return false, nil | ||
| } | ||
| return false, fmt.Errorf("getting config map %s for job %s: %w", key.Name, job.Name, err) | ||
| } | ||
| return configDataHash(cm.Data) != stamped, nil | ||
| } | ||
|
|
||
| // configDataHash is a stable digest of ConfigMap data: keys sorted, each key and value | ||
| // length-prefixed so neither can bleed into the next. | ||
| func configDataHash(data map[string]string) string { | ||
| keys := make([]string, 0, len(data)) | ||
| for k := range data { | ||
| keys = append(keys, k) | ||
| } | ||
| sort.Strings(keys) | ||
| h := sha256.New() | ||
| for _, k := range keys { | ||
| _, _ = fmt.Fprintf(h, "%d:%s%d:%s", len(k), k, len(data[k]), data[k]) | ||
| } | ||
| return hex.EncodeToString(h.Sum(nil)) | ||
| } | ||
|
|
||
| // shouldRecordCompletion is the re-serve guard. It records a completion only when the | ||
| // package's node-state entry is still at this Job's stage and not yet complete: a valid | ||
| // forward transition. An absent entry (uninstalled or node reset), an already-complete | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -237,6 +237,95 @@ var _ = Describe("JobReconcile", func() { | |
| Expect(getNodeState(r)[pkgRef.GetUniqueName()].Stage).To(Equal(v1alpha1.StageConfig)) | ||
| }) | ||
|
|
||
| // A config-update reset puts the entry back at (config, in_progress), the same shape the old | ||
| // run had, so the stamped ConfigMap hash is the only thing that says which data a Job saw. | ||
| Describe("config Job completion after the ConfigMap changed", func() { | ||
| configMap := func(data map[string]string) *corev1.ConfigMap { | ||
| return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ | ||
| Namespace: namespace, | ||
| Name: packageConfigMapName(skyhookName, pkgRef.Name, pkgRef.Version), | ||
| }, Data: data} | ||
| } | ||
| configJob := func(stamped map[string]string) *batchv1.Job { | ||
| job := packageJob(v1alpha1.StageConfig, false, trueCondition(batchv1.JobComplete, "")) | ||
| if stamped != nil { | ||
| job.Annotations[annotationConfigHash] = configDataHash(stamped) | ||
| } | ||
| return job | ||
| } | ||
|
|
||
| It("does not record a Job that ran on data since replaced, and drops the Job", func() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. None of these specs exercise the uncached read from 3048e19: the reconciler gets the same fake client as both cached and uncached reader (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added. The spec at |
||
| node := nodeWithState(v1alpha1.StateInProgress, v1alpha1.StageConfig) | ||
| job := configJob(map[string]string{"a.properties": "old"}) | ||
| r := newReconciler(node, job, configMap(map[string]string{"a.properties": "new"})) | ||
|
|
||
| _, err := r.JobReconcile(ctx, job) | ||
| Expect(err).ToNot(HaveOccurred()) | ||
|
|
||
| Expect(getNodeState(r)[pkgRef.GetUniqueName()].State).To(Equal(v1alpha1.StateInProgress)) | ||
| err = r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: job.Name}, &batchv1.Job{}) | ||
| Expect(apierrors.IsNotFound(err)).To(BeTrue(), "the stale Job is deleted so the stage re-runs") | ||
| }) | ||
|
|
||
| It("judges the Job by the apiserver's ConfigMap, not a cache that still holds the old one", func() { | ||
| node := nodeWithState(v1alpha1.StateInProgress, v1alpha1.StageConfig) | ||
| job := configJob(map[string]string{"a.properties": "old"}) | ||
|
|
||
| scheme := runtime.NewScheme() | ||
| Expect(corev1.AddToScheme(scheme)).To(Succeed()) | ||
| Expect(batchv1.AddToScheme(scheme)).To(Succeed()) | ||
| Expect(v1alpha1.AddToScheme(scheme)).To(Succeed()) | ||
| // the cache still serves the data the Job ran on; only the live reader has the new one | ||
| cached := fake.NewClientBuilder().WithScheme(scheme).WithObjects(node, job, configMap(map[string]string{"a.properties": "old"})).Build() | ||
| live := fake.NewClientBuilder().WithScheme(scheme).WithObjects(configMap(map[string]string{"a.properties": "new"})).Build() | ||
| r := NewJobReconciler(cached, live, k8sfake.NewClientset(), events.NewFakeRecorder(50), validOpts().JobOperatorOptions) | ||
|
|
||
| _, err := r.JobReconcile(ctx, job) | ||
| Expect(err).ToNot(HaveOccurred()) | ||
|
|
||
| Expect(getNodeState(r)[pkgRef.GetUniqueName()].State).To(Equal(v1alpha1.StateInProgress)) | ||
| }) | ||
|
|
||
| It("records a Job whose data still matches", func() { | ||
| node := nodeWithState(v1alpha1.StateInProgress, v1alpha1.StageConfig) | ||
| data := map[string]string{"a.properties": "new"} | ||
| job := configJob(data) | ||
| r := newReconciler(node, job, configMap(data)) | ||
|
|
||
| _, err := r.JobReconcile(ctx, job) | ||
| Expect(err).ToNot(HaveOccurred()) | ||
|
|
||
| Expect(getNodeState(r)[pkgRef.GetUniqueName()].State).To(Equal(v1alpha1.StateComplete)) | ||
| }) | ||
|
|
||
| It("records a Job created without the stamp", func() { | ||
| node := nodeWithState(v1alpha1.StateInProgress, v1alpha1.StageConfig) | ||
| job := configJob(nil) | ||
| r := newReconciler(node, job, configMap(map[string]string{"a.properties": "new"})) | ||
|
|
||
| _, err := r.JobReconcile(ctx, job) | ||
| Expect(err).ToNot(HaveOccurred()) | ||
|
|
||
| Expect(getNodeState(r)[pkgRef.GetUniqueName()].State).To(Equal(v1alpha1.StateComplete)) | ||
| }) | ||
|
|
||
| It("records when the ConfigMap is gone", func() { | ||
| node := nodeWithState(v1alpha1.StateInProgress, v1alpha1.StageConfig) | ||
| job := configJob(map[string]string{"a.properties": "old"}) | ||
| r := newReconciler(node, job) | ||
|
|
||
| _, err := r.JobReconcile(ctx, job) | ||
| Expect(err).ToNot(HaveOccurred()) | ||
|
|
||
| Expect(getNodeState(r)[pkgRef.GetUniqueName()].State).To(Equal(v1alpha1.StateComplete)) | ||
| }) | ||
|
|
||
| It("hashes the data independent of key order and without key/value bleed", func() { | ||
| Expect(configDataHash(map[string]string{"a": "1", "b": "2"})).To(Equal(configDataHash(map[string]string{"b": "2", "a": "1"}))) | ||
| Expect(configDataHash(map[string]string{"a": "bc"})).ToNot(Equal(configDataHash(map[string]string{"ab": "c"}))) | ||
| }) | ||
| }) | ||
|
|
||
| It("does not regress a stage when a completion is re-served after the node advanced", func() { | ||
| // The apply Job re-fires, but node state has already moved on to config. | ||
| node := nodeWithState(v1alpha1.StateInProgress, v1alpha1.StageConfig) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds a deletion of an unprocessed
CompleteJob, but rule 2 (line 226) still says "Unprocessed finished Jobs are never deleted except by the two sweeps", and line 233 still says config-update handling deletes only "unfinished or terminally failed" Jobs, which has been stale since #788. Since this PR edits the doc, could rule 2 list both exceptions (this completion check and #788's reset-time deletion) and line 233 be updated?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Rule 2 now names both exceptions, the config-hash check on a config-stage completion and the reset's deletion of unfinished, failed, or unrecorded
Completeconfig Jobs. The paragraph that said "unfinished or terminally failed" is rewritten (03e975f, 462ff47).