Repository: kubernetes-sigs/kueue
Pull request #5252: Fix Kueue ownership traversal resulting in permission errors
Author: login: mimowo
Labels: name: kind/bug
description: Categorizes issue or PR as related to a bug.
name: release-note
description: Denotes a PR that will be considered when it comes time to generate release notes.
name: size/L
description: Denotes a PR that changes 100-499 lines, ignoring generated files.
name: lgtm
description: "Looks good to me", indicates that a PR is ready to be merged.
name: kind/regression
description: Categorizes issue or PR as related to a regression from a prior release.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: cncf-cla: yes
description: Indicates the PR's author has signed the CNCF CLA.
Created: 2025-05-15T07:20:02Z | Merged: 2025-05-15T17:37:14Z

#### What type of PR is this?

/kind bug
/kind regression
<!--
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #5235

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->
```release-note
Fix the bug which prevented running Jobs (with queue-name label) owned by other Jobs for which Kueue does not 
have the necessary RBAC permissions (for example kserve or CronJob).
```

Linked issue #5235: Kueue controller is trying to access third party GVKs which doesn't have access to
**What happened**:

I'm trying to use Kueue with Kserve for model inference (through the Deployment and Pod integration) and I'm seeing many error logs in the Kueue controller manager like:

```
W0512 16:37:49.006396       1 reflector.go:569] sigs.k8s.io/controller-runtime/pkg/cache/internal/informers.go:108: failed to list *v1.PartialObjectMetadata: inferenceservices.serving.kserve.io is forbidden: User "system:serviceaccount:kueue-system:kueue-controller-manager" cannot list resource "inferenceservices" in API group "serving.kserve.io" at the cluster scope
E0512 16:37:49.006442       1 reflector.go:166] "Unhandled Error" err="sigs.k8s.io/controller-runtime/pkg/cache/internal/informers.go:108: Failed to watch *v1.PartialObjectMetadata: failed to list *v1.PartialObjectMetadata: inferenceservices.serving.kserve.io is forbidden: User \"system:serviceaccount:kueue-system:kueue-controller-manager\" cannot list resource \"inferenceservices\" in API group \"serving.kserve.io\" at the cluster scope" logger="UnhandledError"
```

**What you expected to happen**:

No error logs.

**How to reproduce it (as minimally and precisely as possible)**:

1. Clone this repo https://github.com/pierDipi/kueue-access-unknown-gvks
    `git clone git@github.com:pierDipi/kueue-access-unknown-gvks.git`
1. Run `kind create cluster`
2. Run `./repro.sh`
3. See error logs: `kubectl logs -n kueue-system -l=control-plane=controller-manager -f`

**Anything else we need to know?**: I don't want to give Kueue access to a resource that doesn't need to have access to.

**Environment**: KinD
- Kubernetes version (use `kubectl version`): 
  ```
  $ kubectl version
  # ...
  Server Version: v1.32.2
  ```
- Kueue version (use `git describe --tags --dirty --always`): 0.11.4 (latest as of today)
- Cloud provider or hardware configuration: N/A
- OS (e.g: `cat /etc/os-release`): Linux (Fedora)
- Kernel (e.g. `uname -a`): N/A
Linked issue comments: user: login: mimowo
created_at: 2025-05-12T18:00:32Z
body: This is probably related to the recent updates in [FindAncestorJobManagedByKueue](https://github.com/kubernetes-sigs/kueue/blob/0db959e83edda267222125cbca98ab1f41c796ac/pkg/controller/jobframework/reconciler.go#L691). 

The intention was two be able to support AppWrapper and be able to traverse the ownership chain without requiring management of the CRDs on the way. 

I'm wondering if we could patch this just by checking in advance if Kueue has the required access to read the CRD. If it does not have, then it will not work anyway.

cc @dgrove-oss  @tenzen-y @mbobrovskyi 
author_association: CONTRIBUTOR
user: login: dgrove-oss
created_at: 2025-05-12T18:49:02Z
body: We probably should patch it by catching the error, realizing we don't have read access, logging it at the debug level, and not returning an error to the controller runtime.

It's not directly relevant, but I did notice that in the [provided reproducer yaml,](https://github.com/pierDipi/kueue-access-unknown-gvks/blob/9626645572a7990b12f771d00f61955ea1422027/500-isvc.yaml#L6-#L20) the `InferenceService` has a queue-name label.  If Kueue isn't allowed to read InferenceService, that label is not going to be useful.


author_association: MEMBER
user: login: mimowo
created_at: 2025-05-12T18:55:48Z
body: > We probably should patch it by catching the error, realizing we don't have read access, logging it at the debug level, and not returning an error to the controller runtime.

right this would work but realizing we don't have read access may require parsing the error which isn't clean, it might be a necessity. Im wondering if there is some API/function which would allow to check us if we have the read access prior to the attempt of reading.
author_association: CONTRIBUTOR
user: login: pierDipi
created_at: 2025-05-13T07:08:57Z
body: > It's not directly relevant, but I did notice that in the [provided reproducer yaml,](https://github.com/pierDipi/kueue-access-unknown-gvks/blob/9626645572a7990b12f771d00f61955ea1422027/500-isvc.yaml#L6-#L20) the `InferenceService` has a queue-name label. If Kueue isn't allowed to read InferenceService, that label is not going to be useful.

As a note, the label is at the InferenceService level because we leverage Kserve labels propagation to propagete the Queue label to the Deployment/Pods.
author_association: NONE
user: login: pierDipi
created_at: 2025-05-13T07:12:10Z
body: > Im wondering if there is some API/function which would allow to check us if we have the read access prior to the attempt of reading

We can use `SelfSubjectAccessReview` API with get, list, watch verbs?
- https://kubernetes.io/docs/reference/access-authn-authz/authorization/#checking-api-access
- https://kubernetes.io/docs/reference/kubernetes-api/authorization-resources/self-subject-access-review-v1/
author_association: NONE
user: login: mimowo
created_at: 2025-05-13T07:30:17Z
body: Looks promising, we would need to check how this can be used by a go-client in Kueue code.
author_association: CONTRIBUTOR
user: login: kannon92
created_at: 2025-05-13T23:42:34Z
body: I think this is another example of this error manifesting itself:

https://github.com/kubernetes-sigs/kueue/issues/4141#issuecomment-2877537125
author_association: CONTRIBUTOR
user: login: mimowo
created_at: 2025-05-14T12:42:29Z
body: /assign 
Tentatively, as this is an important regression. 

However, I'm loaded with other release-related tasks, so if there is someone who is willing to look into this, then be my guest :)
author_association: CONTRIBUTOR
user: login: qti-haeyoon
created_at: 2025-05-15T04:16:52Z
body: Regardless of the planned patch (which is to check access before attempting to read), should users update the Kueue controller's [ClusterRole](https://github.com/kubernetes-sigs/kueue/blob/main/charts/kueue/templates/rbac/role.yaml) manifest if we use third party GVK?
author_association: CONTRIBUTOR
user: login: mimowo
created_at: 2025-05-15T06:50:49Z
body: /kind regression
Since this was not an issue prior to 0.11.4.

author_association: CONTRIBUTOR
user: login: mimowo
created_at: 2025-05-15T07:00:26Z
body: > Regardless of the planned patch (which is to check access before attempting to read), should users update the Kueue controller's [ClusterRole](https://github.com/kubernetes-sigs/kueue/blob/main/charts/kueue/templates/rbac/role.yaml) manifest if we use third party GVK?

This is a good question. The answer will depend on the way we decide to fix it, but I would strive to preserve the previous behavior (before 0.11.4), so this is not needed. 

I believe the problematic PR is this: https://github.com/kubernetes-sigs/kueue/pull/4824, which tries to walk up the owner reference even outside of frameworks recognized by Kueue. This may be a nice feature, but I don't think this was required as a bugfix. This PR aimed to support AppWrapper properly it seems in cases where the interim framework is either custom or known but disabled.

However, the problem with that is we may walk into frameworks we don't have permission for. And checking permission is hard. Yes, we have `SelfSubjectAccessReview`, but this is actually a wrapper around a POST call to the API-server. I don't think we can afford sending these posts from our webhooks (and the traversal function is used in webhooks). Similarly, I don't think even capturing the error as suggested in https://github.com/kubernetes-sigs/kueue/issues/5235#issuecomment-2873659015 would not be feasible, because it means that controller-runtime repeatedly will send requests to API server to LIST the resource and fail, this seems like too much churn.

So, the way I would propose to fix the regression, and yet maintain the fix  https://github.com/kubernetes-sigs/kueue/pull/4824 would be to only walk up the ownership over known frameworks (enabled + disabled + listed in externalFrameworks). This way we make sure, that if an admin wants some custom framework to work with AppWrapper they can list it inside externalFrameworks. And to be fair, it is probably ok to only walk over enabled + externalFrameworks. Then users of AppWrapper who want to use it for disabled built-in integrations could add the integration into the list of externalFrameworks.

If this approach gets support it will not require changes to the permissions.

cc @tenzen-y @dgrove-oss @PBundyra @gabesaba let me know if you have some opinions.
author_association: CONTRIBUTOR
user: login: tenzen-y
created_at: 2025-05-15T08:41:05Z
body: > And to be fair, it is probably ok to only walk over enabled + externalFrameworks. Then users of AppWrapper who want to use it for disabled built-in integrations could add the integration into the list of externalFrameworks.

Does this mean the following framework enabling and disabling?

```yaml
integration:
  frameworks:
  - "batch/job"
  - "workload.codeflare.dev/appwrapper"
  externalFrameworks:
  - "jobset.x-k8s.io/jobset"
```
author_association: MEMBER
user: login: mimowo
created_at: 2025-05-15T09:03:38Z
body: Yes, but I'm hesitant between two options, you posted like in (1.):

1. 
- only consider looking up for workload for enabled integrations + externalFrameworks
- walk up ownership only for enabled integrations + externalFrameworks (stop on disabled integrations)

2. 
- only consider looking up for workload for enabled integrations + externalFrameworks
- walk up ownership only for enabled and disabled built-in integrations + externalFrameworks

I think actually (2.) might be less work, and less breaking to admins. It should be ok, because for the disabled integrations we provide RBAC permissions with Kueue, so we can walk up.  This would be minimal fix to only exclude walking up the tree for unknown frameworks completely. 

So, I'm now leaning to (2). wdyt? 

EDIT: in other words, externalFrameworks would only be needed for CustomJobs wrapped by AppWrapper. Not for JobSet as Kueue knows it provides RBAC for JobSet.
author_association: CONTRIBUTOR
user: login: dgrove-oss
created_at: 2025-05-15T13:01:16Z
body: My recollection of the situation we were trying to fix by doing this is that we have to walk through disabled integrations (eg, suppose Job integration is disabled, but JobSet is enabled).  But option 2 should work.... we look through any kind that Kueue is capable of managing (enabled or disabled), but stop when we find something we don't know about.  And it is simpler since we don't have to do a potentially complex subject access review.
author_association: MEMBER
user: login: tenzen-y
created_at: 2025-05-15T15:30:16Z
body: > I think actually (2.) might be less work, and less breaking to admins. It should be ok, because for the disabled integrations we provide RBAC permissions with Kueue, so we can walk up. This would be minimal fix to only exclude walking up the tree for unknown frameworks completely.
> 
> So, I'm now leaning to (2). wdyt?

I'm fine with (2). Today, we have StatefulSet and Deployment supports. The popular OSS projects often depend on those core resources, like Knative. I think (2) would work well.

author_association: MEMBER

Diff:
diff --git a/pkg/controller/jobframework/integrationmanager.go b/pkg/controller/jobframework/integrationmanager.go
index 812404a07b4..a2e0e3785a2 100644
--- a/pkg/controller/jobframework/integrationmanager.go
+++ b/pkg/controller/jobframework/integrationmanager.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"maps"
 	"slices"
 	"sort"
 	"sync"
@@ -198,6 +199,23 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	// ReplicaSet is an interim owner from Pod to Deployment. We call it known
+	// so that the users don't need to list	it explicitly in their configs.
+	// Note that Kueue provides RBAC permissions allowing for traversal over it.
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
+}
+
 func (m *integrationManager) getJobTypeForOwner(ownerRef *metav1.OwnerReference) runtime.Object {
 	for jobKey := range m.getEnabledIntegrations() {
 		cbs, found := m.integrations[jobKey]
@@ -286,6 +304,23 @@ func EnableIntegrationsForTest(tb testing.TB, names ...string) func() {
 	}
 }
 
+// EnableExternalIntegrationsForTest - should be used only in tests
+// Mark the frameworks identified by names and return a revert function.
+func EnableExternalIntegrationsForTest(tb testing.TB, names ...string) func() {
+	tb.Helper()
+	old := maps.Clone(manager.externalIntegrations)
+	for _, name := range names {
+		if err := manager.registerExternal(name); err != nil {
+			tb.Fatalf("failed to register external framework: %q", name)
+		}
+	}
+	return func() {
+		manager.mu.Lock()
+		manager.externalIntegrations = old
+		manager.mu.Unlock()
+	}
+}
+
 // GetIntegration looks-up the framework identified by name in the currently registered
 // list of frameworks returning its callbacks and true if found.
 func GetIntegration(name string) (IntegrationCallbacks, bool) {
diff --git a/pkg/controller/jobframework/reconciler.go b/pkg/controller/jobframework/reconciler.go
index 63b0e90552e..a0e0b385f6b 100644
--- a/pkg/controller/jobframework/reconciler.go
+++ b/pkg/controller/jobframework/reconciler.go
@@ -715,9 +715,14 @@ func FindAncestorJobManagedByKueue(ctx context.Context, c client.Client, jobObj
 
 		owner := metav1.GetControllerOf(currentObj)
 		if owner == nil {
+			log.V(3).Info("stop walking up as the owner is not found", "owner", klog.KObj(currentObj))
 			return topLevelJob, nil
 		}
 
+		if !manager.isKnownOwner(owner) {
+			log.V(3).Info("stop walking up as the owner is not known", "owner", klog.KObj(currentObj))
+			return topLevelJob, nil
+		}
 		parentObj := GetEmptyOwnerObject(owner)
 		managed := parentObj != nil
 		if parentObj == nil {
diff --git a/pkg/controller/jobframework/reconciler_test.go b/pkg/controller/jobframework/reconciler_test.go
index 9b09c951d9d..0477c0cf8f7 100644
--- a/pkg/controller/jobframework/reconciler_test.go
+++ b/pkg/controller/jobframework/reconciler_test.go
@@ -24,12 +24,14 @@ import (
 	"github.com/google/go-cmp/cmp/cmpopts"
 	kfmpi "github.com/kubeflow/mpi-operator/pkg/apis/kubeflow/v2beta1"
 	awv1beta2 "github.com/project-codeflare/appwrapper/api/v1beta2"
+	appsv1 "k8s.io/api/apps/v1"
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/utils/clock"
 	testingclock "k8s.io/utils/clock/testing"
+	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/jobset/api/jobset/v1alpha2"
 
@@ -38,6 +40,7 @@ import (
 	"sigs.k8s.io/kueue/pkg/util/kubeversion"
 	utiltesting "sigs.k8s.io/kueue/pkg/util/testing"
 	testingaw "sigs.k8s.io/kueue/pkg/util/testingjobs/appwrapper"
+	testingdeployment "sigs.k8s.io/kueue/pkg/util/testingjobs/deployment"
 	testingjob "sigs.k8s.io/kueue/pkg/util/testingjobs/job"
 	"sigs.k8s.io/kueue/pkg/util/testingjobs/jobset"
 	testingmpijob "sigs.k8s.io/kueue/pkg/util/testingjobs/mpijob"
@@ -69,6 +72,7 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 	cases := map[string]struct {
 		manageJobsWithoutQueueName bool
 		integrations               []string
+		externalFrameworks         []string
 		ancestors                  []client.Object
 		job                        client.Object
 		wantManaged                client.Object
@@ -315,10 +319,80 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 				Obj(),
 			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Obj(),
 		},
+		"Job -> CronJob (external framework, not enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations: []string{"workload.codeflare.dev/appwrapper"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/appwrapper",
+							Kind:       "AppWrapper",
+							UID:        "aw",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("job", jobNamespace).UID("job").
+				OwnerReference("cronjob", batchv1.SchemeGroupVersion.WithKind("CronJob")).
+				Obj(),
+		},
+		"Job -> CronJob (external framework, enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations:       []string{"workload.codeflare.dev/appwrapper"},
+			externalFrameworks: []string{"CronJob.v1.batch"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/v1beta2",
+							Kind:       "AppWrapper",
+							UID:        "aw",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("job", jobNamespace).UID("job").
+				OwnerReference("cronjob", batchv1.SchemeGroupVersion.WithKind("CronJob")).
+				Obj(),
+			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+		},
+		"Pod -> ReplicaSet -> Deployment (queue-name) => Deployment": {
+			integrations: []string{"pod", "deployment"},
+			ancestors: []client.Object{
+				testingdeployment.MakeDeployment("deploy", jobNamespace).UID("deploy").Queue("test-q").Obj(),
+				&appsv1.ReplicaSet{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "rs",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "deploy",
+							APIVersion: "apps/v1",
+							Kind:       "Deployment",
+							UID:        "deploy",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("pod", jobNamespace).UID("pod").
+				OwnerReference("rs", appsv1.SchemeGroupVersion.WithKind("ReplicaSet")).
+				Obj(),
+			wantManaged: testingdeployment.MakeDeployment("deploy", jobNamespace).UID("deploy").Queue("test-q").Obj(),
+		},
 	}
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
 			t.Cleanup(EnableIntegrationsForTest(t, tc.integrations...))
+			t.Cleanup(EnableExternalIntegrationsForTest(t, tc.externalFrameworks...))
 			ctx, _ := utiltesting.ContextWithLog(t)
 			recorder := &utiltesting.EventRecorder{}
 			builder := utiltesting.NewClientBuilder(kfmpi.AddToScheme, awv1beta2.AddToScheme, v1alpha2.AddToScheme)
diff --git a/pkg/controller/jobs/job/job_webhook_test.go b/pkg/controller/jobs/job/job_webhook_test.go
index c00cb1b0a1a..15f24687b38 100644
--- a/pkg/controller/jobs/job/job_webhook_test.go
+++ b/pkg/controller/jobs/job/job_webhook_test.go
@@ -700,7 +700,6 @@ func TestDefault(t *testing.T) {
 				OwnerReference("owner", jobsetapi.SchemeGroupVersion.WithKind("JobSet")).
 				Queue("default").
 				Obj(),
-			wantErr: jobframework.ErrWorkloadOwnerNotFound,
 		},
 	}
 	for name, tc := range testcases {
diff --git a/test/e2e/singlecluster/e2e_test.go b/test/e2e/singlecluster/e2e_test.go
index 530725152a2..68a7e641985 100644
--- a/test/e2e/singlecluster/e2e_test.go
+++ b/test/e2e/singlecluster/e2e_test.go
@@ -17,10 +17,13 @@ limitations under the License.
 package e2e
 
 import (
+	"time"
+
 	"github.com/onsi/ginkgo/v2"
 	"github.com/onsi/gomega"
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/utils/ptr"
@@ -118,6 +121,85 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
+			cronJob := &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-cronjob",
+					Namespace: ns.Name,
+				},
+				Spec: batchv1.CronJobSpec{
+					Schedule:          "* * * * *",
+					ConcurrencyPolicy: batchv1.ForbidConcurrent,
+					JobTemplate: batchv1.JobTemplateSpec{
+						ObjectMeta: metav1.ObjectMeta{
+							Labels: map[string]string{
+								constants.QueueLabel: localQueue.Name,
+							},
+						},
+						Spec: batchv1.JobSpec{
+							Template: corev1.PodTemplateSpec{
+								Spec: corev1.PodSpec{
+									RestartPolicy: corev1.RestartPolicyNever,
+									Containers: []corev1.Container{
+										{
+											Name:    "c",
+											Image:   util.E2eTestAgnHostImage,
+											Command: util.BehaviorExitFast,
+											Resources: corev1.ResourceRequirements{
+												Requests: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+												Limits: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}
+			util.MustCreate(ctx, k8sClient, cronJob)
+			ginkgo.DeferCleanup(func() {
+				gomega.Expect(util.DeleteAllCronJobsInNamespace(ctx, k8sClient, ns)).Should(gomega.Succeed())
+			})
+
+			ginkgo.By("Patch the last start time to be in the past so that it starts immediately", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cronJob), cronJob)).To(gomega.Succeed())
+					nextSchedule := cronJob.CreationTimestamp.Add(-2 * time.Minute)
+					cronJob.Status.LastScheduleTime = ptr.To(metav1.Time{Time: nextSchedule})
+					g.Expect(k8sClient.Status().Update(ctx, cronJob)).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
+
+			createJobs := &batchv1.JobList{}
+			ginkgo.By("Check that the Job is create and retrieve it", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.List(ctx, createJobs, client.InNamespace(ns.Name))).To(gomega.Succeed())
+					g.Expect(createJobs.Items).To(gomega.HaveLen(1))
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
+
+			createdJob := createJobs.Items[0]
+			ginkgo.By("verify the job has the nodeSelector assigned", func() {
+				jobKey := client.ObjectKeyFromObject(&createdJob)
+				util.ExpectJobUnsuspendedWithNodeSelectors(ctx, k8sClient, jobKey, map[string]string{
+					"instance-type": "on-demand",
+				})
+			})
+			ginkgo.By("verify the workload was created and admitted for the Job", func() {
+				createdWorkload := &kueue.Workload{}
+				wlLookupKey := types.NamespacedName{Name: workloadjob.GetWorkloadNameForJob(createdJob.Name, createdJob.UID), Namespace: ns.Name}
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, wlLookupKey, createdWorkload)).Should(gomega.Succeed())
+					g.Expect(workload.HasQuotaReservation(createdWorkload)).Should(gomega.BeTrue())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
+		})
+
 		ginkgo.It("Should unsuspend a job and set nodeSelectors", func() {
 			// Use a binary that ends.
 			sampleJob = (&testingjob.JobWrapper{Job: *sampleJob}).Image(util.E2eTestAgnHostImage, util.BehaviorExitFast).Obj()
diff --git a/test/util/util.go b/test/util/util.go
index 4764918225c..2e1022fd530 100644
--- a/test/util/util.go
+++ b/test/util/util.go
@@ -158,6 +158,10 @@ func DeleteNamespace(ctx context.Context, c client.Client, ns *corev1.Namespace)
 	return nil
 }
 
+func DeleteAllCronJobsInNamespace(ctx context.Context, c client.Client, ns *corev1.Namespace) error {
+	return deleteAllObjectsInNamespace(ctx, c, ns, &batchv1.CronJob{})
+}
+
 func DeleteAllJobsInNamespace(ctx context.Context, c client.Client, ns *corev1.Namespace) error {
 	return deleteAllObjectsInNamespace(ctx, c, ns, &batchv1.Job{})
 }


Reviews:
user: login: kannon92
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:49:49Z
user: login: kannon92
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:51:22Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:58:42Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T16:00:14Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T16:27:06Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T16:45:54Z
user: login: dgrove-oss
body: lgtm. 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T16:52:38Z
user: login: dgrove-oss
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T17:09:45Z
user: login: kannon92
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:10:01Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:11:25Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:14:38Z
user: login: dgrove-oss
body: /lgtm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T17:22:52Z
user: login: kannon92
body: /lgtm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:30:01Z
user: login: tenzen-y
body: Thank you
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T04:00:30Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-06-09T13:57:08Z
user: login: tenzen-y
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-08-26T10:59:26Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-10-07T08:10:45Z
user: login: mimowo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-10-07T09:11:07Z

Review comments:
diff_hunk: @@ -118,6 +119,78 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
path: test/e2e/singlecluster/e2e_test.go
user: login: kannon92
body: Great test case! Thanks for adding it.
created_at: 2025-05-15T15:49:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: kannon92
body: The first two blocks of code make sense to me.

The replicaSet does not. I'm sure this is correct but could you add a comment here explaining why?
created_at: 2025-05-15T15:51:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: mimowo
body: Sure, we need to be able to walk up the ownerhip chain for Pod -> ReplicaSet -> Deployment. 

However, ReplicaSet is "technical detail" rather than an integration a user would like to enable or see in the config.
created_at: 2025-05-15T15:58:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +119,78 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
path: test/e2e/singlecluster/e2e_test.go
user: login: mimowo
body: Thanks, the only downside is that sometimes the Job gets 1min to be created by the CronJob, because CronJob seems to only create with 1min resolution. I'm wondering if we could trick it: 
- create CronJob suspended
- patch CronJob status to indicate the last schedule was over 1min ago
- resume

I will check if this works, because I don't like wasting 1min (or 30s on average) for this test.
created_at: 2025-05-15T16:00:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: mimowo
body: /hold
We have had one e2e test for this scenario, but we are missing unit test, I will add.
created_at: 2025-05-15T16:27:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: mimowo
body: /unhold
added under "Pod -> ReplicaSet -> Deployment (queue-name) => Deployment". I tested it fails without this line.
created_at: 2025-05-15T16:45:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: dgrove-oss
body: Makes sense.  Thanks for adding the extra test case.
created_at: 2025-05-15T16:49:09Z
author_association: MEMBER
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: kannon92
body: I meant a comment in the code..
created_at: 2025-05-15T17:10:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: mimowo
body: will do soon
created_at: 2025-05-15T17:11:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,20 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: mimowo
body: Done, ptal
created_at: 2025-05-15T17:14:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,6 +199,23 @@ func (m *integrationManager) getList() []string {
 	return ret
 }
 
+func (m *integrationManager) isKnownOwner(ownerRef *metav1.OwnerReference) bool {
+	for _, cbs := range m.integrations {
+		if cbs.matchingOwnerReference(ownerRef) {
+			return true
+		}
+	}
+	for _, jt := range m.externalIntegrations {
+		if ownerReferenceMatchingGVK(ownerRef, jt.GetObjectKind().GroupVersionKind()) {
+			return true
+		}
+	}
+	// ReplicaSet is an interim owner from Pod to Deployment. We call it known
+	// so that the users don't need to list	it explicitly in their configs.
+	// Note that Kueue provides RBAC permissions allowing for traversal over it.
+	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == "apps/v1"
path: pkg/controller/jobframework/integrationmanager.go
user: login: tenzen-y
body: ```suggestion
	return ownerRef.Kind == "ReplicaSet" && ownerRef.APIVersion == appsv1.SchemeGroupVersion.String()
```

Let's use the constants instead of hardcoded value.

created_at: 2025-05-16T03:45:53Z
author_association: MEMBER
diff_hunk: @@ -315,10 +319,80 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 				Obj(),
 			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Obj(),
 		},
+		"Job -> CronJob (external framework, not enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations: []string{"workload.codeflare.dev/appwrapper"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/appwrapper",
path: pkg/controller/jobframework/reconciler_test.go
user: login: tenzen-y
body: ```suggestion
							APIVersion: awv1beta2.GroupVersion.String(),
```
created_at: 2025-05-16T03:48:23Z
author_association: MEMBER
diff_hunk: @@ -315,10 +319,80 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 				Obj(),
 			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Obj(),
 		},
+		"Job -> CronJob (external framework, not enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations: []string{"workload.codeflare.dev/appwrapper"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/appwrapper",
+							Kind:       "AppWrapper",
path: pkg/controller/jobframework/reconciler_test.go
user: login: tenzen-y
body: ```suggestion

							Kind:       awvv1beta2.AppWrapperKind,
```
created_at: 2025-05-16T03:48:57Z
author_association: MEMBER
diff_hunk: @@ -315,10 +319,80 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 				Obj(),
 			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Obj(),
 		},
+		"Job -> CronJob (external framework, not enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations: []string{"workload.codeflare.dev/appwrapper"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/appwrapper",
+							Kind:       "AppWrapper",
+							UID:        "aw",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("job", jobNamespace).UID("job").
+				OwnerReference("cronjob", batchv1.SchemeGroupVersion.WithKind("CronJob")).
+				Obj(),
+		},
+		"Job -> CronJob (external framework, enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations:       []string{"workload.codeflare.dev/appwrapper"},
+			externalFrameworks: []string{"CronJob.v1.batch"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/v1beta2",
path: pkg/controller/jobframework/reconciler_test.go
user: login: tenzen-y
body: ```suggestion
							APIVersion: awv1beta2.GroupVersion.String(),
```
created_at: 2025-05-16T03:49:32Z
author_association: MEMBER
diff_hunk: @@ -315,10 +319,80 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 				Obj(),
 			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Obj(),
 		},
+		"Job -> CronJob (external framework, not enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations: []string{"workload.codeflare.dev/appwrapper"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/appwrapper",
+							Kind:       "AppWrapper",
+							UID:        "aw",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("job", jobNamespace).UID("job").
+				OwnerReference("cronjob", batchv1.SchemeGroupVersion.WithKind("CronJob")).
+				Obj(),
+		},
+		"Job -> CronJob (external framework, enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations:       []string{"workload.codeflare.dev/appwrapper"},
+			externalFrameworks: []string{"CronJob.v1.batch"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/v1beta2",
+							Kind:       "AppWrapper",
path: pkg/controller/jobframework/reconciler_test.go
user: login: tenzen-y
body: ```suggestion
							Kind:       awvv1beta2.AppWrapperKind,
```
created_at: 2025-05-16T03:49:51Z
author_association: MEMBER
diff_hunk: @@ -315,10 +319,80 @@ func TestFindAncestorJobManagedByKueue(t *testing.T) {
 				Obj(),
 			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Obj(),
 		},
+		"Job -> CronJob (external framework, not enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations: []string{"workload.codeflare.dev/appwrapper"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/appwrapper",
+							Kind:       "AppWrapper",
+							UID:        "aw",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("job", jobNamespace).UID("job").
+				OwnerReference("cronjob", batchv1.SchemeGroupVersion.WithKind("CronJob")).
+				Obj(),
+		},
+		"Job -> CronJob (external framework, enabled) -> AppWrapper (queue-name) => AppWrapper": {
+			integrations:       []string{"workload.codeflare.dev/appwrapper"},
+			externalFrameworks: []string{"CronJob.v1.batch"},
+			ancestors: []client.Object{
+				testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+				&batchv1.CronJob{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "cronjob",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "aw",
+							APIVersion: "workload.codeflare.dev/v1beta2",
+							Kind:       "AppWrapper",
+							UID:        "aw",
+							Controller: ptr.To(true),
+						}},
+					},
+				},
+			},
+			job: testingjob.MakeJob("job", jobNamespace).UID("job").
+				OwnerReference("cronjob", batchv1.SchemeGroupVersion.WithKind("CronJob")).
+				Obj(),
+			wantManaged: testingaw.MakeAppWrapper("aw", jobNamespace).UID("aw").Queue("test-q").Obj(),
+		},
+		"Pod -> ReplicaSet -> Deployment (queue-name) => Deployment": {
+			integrations: []string{"pod", "deployment"},
+			ancestors: []client.Object{
+				testingdeployment.MakeDeployment("deploy", jobNamespace).UID("deploy").Queue("test-q").Obj(),
+				&appsv1.ReplicaSet{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "rs",
+						Namespace: jobNamespace,
+						OwnerReferences: []metav1.OwnerReference{{
+							Name:       "deploy",
+							APIVersion: "apps/v1",
path: pkg/controller/jobframework/reconciler_test.go
user: login: tenzen-y
body: ```suggestion
							APIVersion:  appsv1.SchemeGroupVersion.String(),
```
created_at: 2025-05-16T03:50:21Z
author_association: MEMBER
diff_hunk: @@ -118,6 +121,85 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
+			cronJob := &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-cronjob",
+					Namespace: ns.Name,
+				},
+				Spec: batchv1.CronJobSpec{
+					Schedule:          "* * * * *",
+					ConcurrencyPolicy: batchv1.ForbidConcurrent,
+					JobTemplate: batchv1.JobTemplateSpec{
+						ObjectMeta: metav1.ObjectMeta{
+							Labels: map[string]string{
+								constants.QueueLabel: localQueue.Name,
+							},
+						},
+						Spec: batchv1.JobSpec{
+							Template: corev1.PodTemplateSpec{
+								Spec: corev1.PodSpec{
+									RestartPolicy: corev1.RestartPolicyNever,
+									Containers: []corev1.Container{
+										{
+											Name:    "c",
+											Image:   util.E2eTestAgnHostImage,
+											Command: util.BehaviorExitFast,
+											Resources: corev1.ResourceRequirements{
+												Requests: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+												Limits: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}
+			util.MustCreate(ctx, k8sClient, cronJob)
+			ginkgo.DeferCleanup(func() {
+				gomega.Expect(util.DeleteAllCronJobsInNamespace(ctx, k8sClient, ns)).Should(gomega.Succeed())
+			})
+
+			ginkgo.By("Patch the last start time to be in the past so that it starts immediately", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cronJob), cronJob)).To(gomega.Succeed())
+					nextSchedule := cronJob.CreationTimestamp.Add(-2 * time.Minute)
+					cronJob.Status.LastScheduleTime = ptr.To(metav1.Time{Time: nextSchedule})
+					g.Expect(k8sClient.Status().Update(ctx, cronJob)).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
path: test/e2e/singlecluster/e2e_test.go
user: login: tenzen-y
body: We might want to move CronJob case to integration tests and emulate the kube-controller-manager behavior like https://github.com/kubernetes/kubectl/blob/88bb12ba04025e5b03a141ad26fca1c735d54e11/pkg/cmd/create/create_job.go#L254-L287

And we can expand Appwrapper E2E to verify the unknown owners.

created_at: 2025-05-16T04:00:26Z
author_association: MEMBER
diff_hunk: @@ -118,6 +121,85 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
+			cronJob := &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-cronjob",
+					Namespace: ns.Name,
+				},
+				Spec: batchv1.CronJobSpec{
+					Schedule:          "* * * * *",
+					ConcurrencyPolicy: batchv1.ForbidConcurrent,
+					JobTemplate: batchv1.JobTemplateSpec{
+						ObjectMeta: metav1.ObjectMeta{
+							Labels: map[string]string{
+								constants.QueueLabel: localQueue.Name,
+							},
+						},
+						Spec: batchv1.JobSpec{
+							Template: corev1.PodTemplateSpec{
+								Spec: corev1.PodSpec{
+									RestartPolicy: corev1.RestartPolicyNever,
+									Containers: []corev1.Container{
+										{
+											Name:    "c",
+											Image:   util.E2eTestAgnHostImage,
+											Command: util.BehaviorExitFast,
+											Resources: corev1.ResourceRequirements{
+												Requests: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+												Limits: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}
+			util.MustCreate(ctx, k8sClient, cronJob)
+			ginkgo.DeferCleanup(func() {
+				gomega.Expect(util.DeleteAllCronJobsInNamespace(ctx, k8sClient, ns)).Should(gomega.Succeed())
+			})
+
+			ginkgo.By("Patch the last start time to be in the past so that it starts immediately", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cronJob), cronJob)).To(gomega.Succeed())
+					nextSchedule := cronJob.CreationTimestamp.Add(-2 * time.Minute)
+					cronJob.Status.LastScheduleTime = ptr.To(metav1.Time{Time: nextSchedule})
+					g.Expect(k8sClient.Status().Update(ctx, cronJob)).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
path: test/e2e/singlecluster/e2e_test.go
user: login: mimowo
body: I would suggest to stay with e2e, for two reasons:
- emulating `kube-controller-manager` is generally hard
- it is easier to test RBAC permissions in e2e, I don't think we install them for integration tests, so it means a lot of extra work. 

wdyt @tenzen-y ?

created_at: 2025-06-09T13:57:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +121,85 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
+			cronJob := &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-cronjob",
+					Namespace: ns.Name,
+				},
+				Spec: batchv1.CronJobSpec{
+					Schedule:          "* * * * *",
+					ConcurrencyPolicy: batchv1.ForbidConcurrent,
+					JobTemplate: batchv1.JobTemplateSpec{
+						ObjectMeta: metav1.ObjectMeta{
+							Labels: map[string]string{
+								constants.QueueLabel: localQueue.Name,
+							},
+						},
+						Spec: batchv1.JobSpec{
+							Template: corev1.PodTemplateSpec{
+								Spec: corev1.PodSpec{
+									RestartPolicy: corev1.RestartPolicyNever,
+									Containers: []corev1.Container{
+										{
+											Name:    "c",
+											Image:   util.E2eTestAgnHostImage,
+											Command: util.BehaviorExitFast,
+											Resources: corev1.ResourceRequirements{
+												Requests: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+												Limits: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}
+			util.MustCreate(ctx, k8sClient, cronJob)
+			ginkgo.DeferCleanup(func() {
+				gomega.Expect(util.DeleteAllCronJobsInNamespace(ctx, k8sClient, ns)).Should(gomega.Succeed())
+			})
+
+			ginkgo.By("Patch the last start time to be in the past so that it starts immediately", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cronJob), cronJob)).To(gomega.Succeed())
+					nextSchedule := cronJob.CreationTimestamp.Add(-2 * time.Minute)
+					cronJob.Status.LastScheduleTime = ptr.To(metav1.Time{Time: nextSchedule})
+					g.Expect(k8sClient.Status().Update(ctx, cronJob)).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
path: test/e2e/singlecluster/e2e_test.go
user: login: tenzen-y
body: IIUC, this test case does not depend on any CronJob features. So, I think we can move this case to integration test, then could reduce E2E test duration.

created_at: 2025-08-26T10:59:25Z
author_association: MEMBER
diff_hunk: @@ -118,6 +121,85 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
+			cronJob := &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-cronjob",
+					Namespace: ns.Name,
+				},
+				Spec: batchv1.CronJobSpec{
+					Schedule:          "* * * * *",
+					ConcurrencyPolicy: batchv1.ForbidConcurrent,
+					JobTemplate: batchv1.JobTemplateSpec{
+						ObjectMeta: metav1.ObjectMeta{
+							Labels: map[string]string{
+								constants.QueueLabel: localQueue.Name,
+							},
+						},
+						Spec: batchv1.JobSpec{
+							Template: corev1.PodTemplateSpec{
+								Spec: corev1.PodSpec{
+									RestartPolicy: corev1.RestartPolicyNever,
+									Containers: []corev1.Container{
+										{
+											Name:    "c",
+											Image:   util.E2eTestAgnHostImage,
+											Command: util.BehaviorExitFast,
+											Resources: corev1.ResourceRequirements{
+												Requests: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+												Limits: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}
+			util.MustCreate(ctx, k8sClient, cronJob)
+			ginkgo.DeferCleanup(func() {
+				gomega.Expect(util.DeleteAllCronJobsInNamespace(ctx, k8sClient, ns)).Should(gomega.Succeed())
+			})
+
+			ginkgo.By("Patch the last start time to be in the past so that it starts immediately", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cronJob), cronJob)).To(gomega.Succeed())
+					nextSchedule := cronJob.CreationTimestamp.Add(-2 * time.Minute)
+					cronJob.Status.LastScheduleTime = ptr.To(metav1.Time{Time: nextSchedule})
+					g.Expect(k8sClient.Status().Update(ctx, cronJob)).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
path: test/e2e/singlecluster/e2e_test.go
user: login: mimowo
body: I'm just not sure we verify RBAC in integration tests, and the bug would only surface at the permission layer.

So, we may need to extend / refactor the integration tests to enable that check.
created_at: 2025-10-07T08:10:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +121,85 @@ var _ = ginkgo.Describe("Kueue", func() {
 			util.ExpectAllPodsInNamespaceDeleted(ctx, k8sClient, ns)
 		})
 
+		ginkgo.It("Should allow to schedule Jobs via CronJob", func() {
+			cronJob := &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-cronjob",
+					Namespace: ns.Name,
+				},
+				Spec: batchv1.CronJobSpec{
+					Schedule:          "* * * * *",
+					ConcurrencyPolicy: batchv1.ForbidConcurrent,
+					JobTemplate: batchv1.JobTemplateSpec{
+						ObjectMeta: metav1.ObjectMeta{
+							Labels: map[string]string{
+								constants.QueueLabel: localQueue.Name,
+							},
+						},
+						Spec: batchv1.JobSpec{
+							Template: corev1.PodTemplateSpec{
+								Spec: corev1.PodSpec{
+									RestartPolicy: corev1.RestartPolicyNever,
+									Containers: []corev1.Container{
+										{
+											Name:    "c",
+											Image:   util.E2eTestAgnHostImage,
+											Command: util.BehaviorExitFast,
+											Resources: corev1.ResourceRequirements{
+												Requests: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+												Limits: corev1.ResourceList{
+													corev1.ResourceCPU: resource.MustParse("1"),
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			}
+			util.MustCreate(ctx, k8sClient, cronJob)
+			ginkgo.DeferCleanup(func() {
+				gomega.Expect(util.DeleteAllCronJobsInNamespace(ctx, k8sClient, ns)).Should(gomega.Succeed())
+			})
+
+			ginkgo.By("Patch the last start time to be in the past so that it starts immediately", func() {
+				gomega.Eventually(func(g gomega.Gomega) {
+					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cronJob), cronJob)).To(gomega.Succeed())
+					nextSchedule := cronJob.CreationTimestamp.Add(-2 * time.Minute)
+					cronJob.Status.LastScheduleTime = ptr.To(metav1.Time{Time: nextSchedule})
+					g.Expect(k8sClient.Status().Update(ctx, cronJob)).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
+			})
path: test/e2e/singlecluster/e2e_test.go
user: login: mimowo
body: @tenzen-y I synced with @kaisoz about moving forward with the "simple" its. As mentioned above the moving of the CronJob test is more involving as it requires enabling RBAC checks in the integration tests so that the test could pick up the original issue for which the  test was added. I believe this requires a separate issue on its own as this is not a "quick follow up" imo.
created_at: 2025-10-07T09:11:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-05-15T07:20:09Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *kubernetes-sigs-kueue* canceled.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 30627abbf77b7298e7bc7493153118bff06495e7 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/kubernetes-sigs-kueue/deploys/682620f3e3a33f00082d11b5 |
author_association: NONE
user: login: mimowo
created_at: 2025-05-15T16:46:17Z
body: @kannon92 @dgrove-oss @tenzen-y This is ready for review I think.
author_association: CONTRIBUTOR
user: login: dgrove-oss
created_at: 2025-05-15T16:52:54Z
body: /lgtm
author_association: MEMBER
user: login: k8s-ci-robot
created_at: 2025-05-15T16:53:01Z
body: LGTM label has been added.  <details>Git tree hash: 5c3c68c4b63070fa551b3eb437e6b4c1e89d2c34</details>
author_association: CONTRIBUTOR
user: login: mimowo
created_at: 2025-05-15T17:07:19Z
body: @dgrove-oss I just reduced the timeouts in e2e tests to LongTimeout. I initially used VeryLongTimeout before I figured out the trick with adjusting the LastScheduleTime. I tested locally in a loop and the job schedules immediately. If it flakes I will bump the timeout, but it is more that needed (from my testing).

PTAL
author_association: CONTRIBUTOR
user: login: mimowo
created_at: 2025-05-15T17:07:52Z
body: /cherry-pick release-0.11
author_association: CONTRIBUTOR
user: login: k8s-infra-cherrypick-robot
created_at: 2025-05-15T17:07:54Z
body: @mimowo: once the present PR merges, I will cherry-pick it on top of `release-0.11` in a new PR and assign it to you.

<details>

In response to [this](https://github.com/kubernetes-sigs/kueue/pull/5252#issuecomment-2884517587):

>/cherry-pick release-0.11


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: CONTRIBUTOR
user: login: dgrove-oss
created_at: 2025-05-15T17:10:01Z
body: /lgtm
author_association: MEMBER
user: login: k8s-ci-robot
created_at: 2025-05-15T17:10:07Z
body: LGTM label has been added.  <details>Git tree hash: 25a63c8a21a41bdad25f17669896f94b82478a68</details>
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-05-15T17:22:58Z
body: LGTM label has been added.  <details>Git tree hash: 6fea3e829d2bf09e262809c5981d252564cc651c</details>
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-05-15T17:22:58Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/kueue/pull/5252#pullrequestreview-2844490020" title="LGTM">dgrove-oss</a>*, *<a href="https://github.com/kubernetes-sigs/kueue/pull/5252#" title="Author self-approved">mimowo</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fkueue).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/kueue/blob/main/OWNERS)~~ [mimowo]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: k8s-infra-cherrypick-robot
created_at: 2025-05-15T17:37:51Z
body: @mimowo: #5252 failed to apply on top of branch "release-0.11":
```
Applying: Fix Kueue owner traversal issue / regression
Using index info to reconstruct a base tree...
M	pkg/controller/jobframework/integrationmanager.go
M	pkg/controller/jobframework/reconciler.go
M	pkg/controller/jobs/job/job_webhook_test.go
M	test/e2e/singlecluster/e2e_test.go
M	test/util/util.go
Falling back to patching base and 3-way merge...
Auto-merging test/util/util.go
Auto-merging test/e2e/singlecluster/e2e_test.go
CONFLICT (content): Merge conflict in test/e2e/singlecluster/e2e_test.go
Auto-merging pkg/controller/jobs/job/job_webhook_test.go
Auto-merging pkg/controller/jobframework/reconciler.go
Auto-merging pkg/controller/jobframework/integrationmanager.go
error: Failed to merge in the changes.
hint: Use 'git am --show-current-patch=diff' to see the failed patch
hint: When you have resolved this problem, run "git am --continue".
hint: If you prefer to skip this patch, run "git am --skip" instead.
hint: To restore the original branch and stop patching, run "git am --abort".
hint: Disable this message with "git config advice.mergeConflict false"
Patch failed at 0001 Fix Kueue owner traversal issue / regression

```

<details>

In response to [this](https://github.com/kubernetes-sigs/kueue/pull/5252#issuecomment-2884517587):

>/cherry-pick release-0.11


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: CONTRIBUTOR