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
57 changes: 57 additions & 0 deletions transform/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
StripDefaultRBACFlag = "strip-default-rbac"
StripDefaultCABundleFlag = "strip-default-cabundle"
PVCRenameMap = "pvc-rename-map"
CraneJobIdempotentAnnotation = "crane.konveyor.io/job-idempotent"
)

const (
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 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 {
return nil, err
}
patches = append(patches, patch...)
}

return patches, nil
}
139 changes: 134 additions & 5 deletions transform/kubernetes/kubernetes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
},
}

Expand Down
Loading