From 642ff525cc7a93b306111826497dd2eef0885a08 Mon Sep 17 00:00:00 2001 From: Marek Aufart Date: Wed, 17 Jun 2026 12:15:43 +0200 Subject: [PATCH 1/2] Suspend standalone Job by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a solution for issue #482 by automatically suspending standalone Kubernetes Jobs (those without ownerReferences) during migration to prevent them from re-executing on the target cluster. Changes: - Added suspendStandaloneJob() function that sets spec.suspend: true on standalone Jobs - Introduced opt-out mechanism via crane.konveyor.io/job-idempotent: "true" annotation for Jobs that are safe to re-run - Jobs with ownerReferences (e.g., created by CronJobs) remain whited out as before - Updated existing Job tests to expect suspend patches Behavior: - By default: standalone Jobs are suspended → safe migration, prevents accidental re-runs - With annotation: Jobs marked as idempotent run automatically → supports init Jobs, DB migrations - User can manually unsuspend Jobs post-migration: kubectl patch job -p '{"spec":{"suspend":false}}' While Kubernetes expects Jobs to be idempotent, many real-world Jobs are not (e.g., one-shot data imports, billing operations). This conservative approach prevents data corruption or duplicate charges while allowing explicit opt-in for idempotent workloads. Fixes: https://github.com/migtools/crane/issues/482 Signed-off-by: Marek Aufart --- transform/kubernetes/kubernetes.go | 57 ++++++++++ transform/kubernetes/kubernetes_test.go | 139 +++++++++++++++++++++++- 2 files changed, 191 insertions(+), 5 deletions(-) diff --git a/transform/kubernetes/kubernetes.go b/transform/kubernetes/kubernetes.go index 9a5d192..158a5f8 100644 --- a/transform/kubernetes/kubernetes.go +++ b/transform/kubernetes/kubernetes.go @@ -33,6 +33,7 @@ const ( StripDefaultRBACFlag = "strip-default-rbac" StripDefaultCABundleFlag = "strip-default-cabundle" PVCRenameMap = "pvc-rename-map" + CraneJobIdempotentAnnotation = "crane.konveyor.io/job-idempotent" ) const ( @@ -431,6 +432,12 @@ func (k *KubernetesTransformPlugin) getKubernetesTransforms(obj unstructured.Uns return nil, err } jsonPatch = append(jsonPatch, patches...) + + patches, err = suspendStandaloneJob(obj) + if err != nil { + return nil, err + } + jsonPatch = append(jsonPatch, patches...) } if replicationControllerGK == obj.GetObjectKind().GroupVersionKind().GroupKind() { js, err := obj.MarshalJSON() @@ -989,3 +996,53 @@ func removeJobControllerUID(obj unstructured.Unstructured) (jsonpatch.Patch, err return patches, nil } + +// suspendStandaloneJob suspends standalone Jobs by default to prevent unintended re-execution +// on the target cluster after migration. +// +// Behavior: +// - Jobs with ownerReferences (e.g., created by CronJob) are NOT suspended - they are whited out +// - Jobs with annotation "crane.konveyor.io/job-idempotent: true" are NOT suspended +// - All other standalone Jobs are suspended (spec.suspend: true) +// +// Rationale: +// Kubernetes best practices expect Jobs to be idempotent, but in practice many Jobs are not +// (e.g., one-shot data imports, billing operations). Re-running such Jobs on migration can cause +// data corruption or duplicate charges. By suspending Jobs by default, we provide a safe migration +// path where users can explicitly opt-in idempotent Jobs via annotation, or manually unsuspend +// Jobs on the target cluster after reviewing their purpose. +// +// Related: https://github.com/migtools/crane/issues/482 +func suspendStandaloneJob(obj unstructured.Unstructured) (jsonpatch.Patch, error) { + var patches jsonpatch.Patch + + // Check if this is a standalone Job (no ownerReferences) + if len(obj.GetOwnerReferences()) > 0 { + // Job has owner (e.g., created by CronJob) - don't suspend + return patches, nil + } + + // Check if Job is explicitly marked as idempotent + annotations := obj.GetAnnotations() + if annotations[CraneJobIdempotentAnnotation] == "true" { + // Job is idempotent, safe to run - don't suspend + return patches, nil + } + + // Check if Job is already suspended + suspended, found, err := unstructured.NestedBool(obj.Object, "spec", "suspend") + if err != nil { + return patches, err + } + + // If already suspended (true) or suspend field doesn't exist, add/set suspend: true + if !found || !suspended { + patch, err := jsonpatch.DecodePatch([]byte(`[{"op": "add", "path": "/spec/suspend", "value": true}]`)) + if err != nil { + return nil, err + } + patches = append(patches, patch...) + } + + return patches, nil +} diff --git a/transform/kubernetes/kubernetes_test.go b/transform/kubernetes/kubernetes_test.go index 257fa9c..bb67e1e 100644 --- a/transform/kubernetes/kubernetes_test.go +++ b/transform/kubernetes/kubernetes_test.go @@ -723,7 +723,7 @@ func TestRun(t *testing.T) { IsWhiteOut: false, Version: "v1", }, - PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"}]`, + PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"add","path":"/spec/suspend","value":true}]`, }, { Name: "RemoveBatchControllerUIDFromJobWithManualSelectorTrue", @@ -765,7 +765,7 @@ func TestRun(t *testing.T) { IsWhiteOut: false, Version: "v1", }, - PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"}]`, + PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"add","path":"/spec/suspend","value":true}]`, }, { Name: "RemoveBatchControllerUIDFromJobWithoutManualSelector", @@ -802,7 +802,7 @@ func TestRun(t *testing.T) { IsWhiteOut: false, Version: "v1", }, - PatchResponseJson: `[{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"}]`, + PatchResponseJson: `[{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"add","path":"/spec/suspend","value":true}]`, }, { Name: "RemoveLegacyControllerUIDFromJob", @@ -844,7 +844,7 @@ func TestRun(t *testing.T) { IsWhiteOut: false, Version: "v1", }, - PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/controller-uid"},{"op":"remove","path":"/metadata/labels/controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/controller-uid"}]`, + PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/controller-uid"},{"op":"remove","path":"/metadata/labels/controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/controller-uid"},{"op":"add","path":"/spec/suspend","value":true}]`, }, { Name: "RemoveBothControllerUIDKeysFromJob", @@ -890,7 +890,136 @@ func TestRun(t *testing.T) { IsWhiteOut: false, Version: "v1", }, - PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/annotations/controller-uid"},{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/labels/controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/controller-uid"}]`, + PatchResponseJson: `[{"op":"remove","path":"/metadata/annotations/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/annotations/controller-uid"},{"op":"remove","path":"/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/metadata/labels/controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/selector/matchLabels/controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/batch.kubernetes.io~1controller-uid"},{"op":"remove","path":"/spec/template/metadata/labels/controller-uid"},{"op":"add","path":"/spec/suspend","value":true}]`, + }, + { + Name: "SuspendStandaloneJobWithoutIdempotentAnnotation", + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Job", + "apiVersion": "batch/v1", + "metadata": map[string]interface{}{ + "name": "standalone-job", + "namespace": "test-namespace", + "labels": map[string]interface{}{ + "app": "test", + }, + "annotations": map[string]interface{}{ + "some-annotation": "some-value", + }, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": "test", + }, + }, + }, + }, + }, + }, + Response: transform.PluginResponse{ + IsWhiteOut: false, + Version: "v1", + }, + PatchResponseJson: `[{"op":"add","path":"/spec/suspend","value":true}]`, + }, + { + Name: "DoNotSuspendStandaloneJobWithIdempotentAnnotation", + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Job", + "apiVersion": "batch/v1", + "metadata": map[string]interface{}{ + "name": "idempotent-job", + "namespace": "test-namespace", + "labels": map[string]interface{}{ + "app": "database-migration", + }, + "annotations": map[string]interface{}{ + "crane.konveyor.io/job-idempotent": "true", + }, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": "database-migration", + }, + }, + }, + }, + }, + }, + Response: transform.PluginResponse{ + IsWhiteOut: false, + Version: "v1", + }, + }, + { + Name: "DoNotSuspendJobWithOwnerReferences", + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Job", + "apiVersion": "batch/v1", + "metadata": map[string]interface{}{ + "name": "cronjob-spawned-job", + "namespace": "test-namespace", + "labels": map[string]interface{}{ + "app": "test", + }, + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "CronJob", + "name": "my-cronjob", + "uid": "12345", + }, + }, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": "test", + }, + }, + }, + }, + }, + }, + Response: transform.PluginResponse{ + IsWhiteOut: true, + Version: "v1", + }, + }, + { + Name: "SuspendStandaloneJobAlreadySuspended", + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Job", + "apiVersion": "batch/v1", + "metadata": map[string]interface{}{ + "name": "already-suspended-job", + "namespace": "test-namespace", + }, + "spec": map[string]interface{}{ + "suspend": true, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": "test", + }, + }, + }, + }, + }, + }, + Response: transform.PluginResponse{ + IsWhiteOut: false, + Version: "v1", + }, }, } From bd2c2538273b89b6efd117a217a3950e44aa83aa Mon Sep 17 00:00:00 2001 From: Marek Aufart Date: Wed, 17 Jun 2026 14:41:05 +0200 Subject: [PATCH 2/2] Fix condition comment Signed-off-by: Marek Aufart --- transform/kubernetes/kubernetes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transform/kubernetes/kubernetes.go b/transform/kubernetes/kubernetes.go index 158a5f8..129ac4a 100644 --- a/transform/kubernetes/kubernetes.go +++ b/transform/kubernetes/kubernetes.go @@ -1035,7 +1035,7 @@ func suspendStandaloneJob(obj unstructured.Unstructured) (jsonpatch.Patch, error return patches, err } - // If already suspended (true) or suspend field doesn't exist, add/set suspend: true + // If suspend field doesn't exist or is currently false, set it to true if !found || !suspended { patch, err := jsonpatch.DecodePatch([]byte(`[{"op": "add", "path": "/spec/suspend", "value": true}]`)) if err != nil {