This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-kubernetes-operator.git
The following commit(s) were added to refs/heads/main by this push:
new cb873488 [SPARK-60042] Support Kueue admission check `podSetUpdates`
cb873488 is described below
commit cb87348882a28506a62f2830dce19de2041900c2
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 7 11:49:39 2026 -0700
[SPARK-60042] Support Kueue admission check `podSetUpdates`
### What changes were proposed in this pull request?
This PR aims to support Kueue admission check `podSetUpdates` like the
Kueue built-in integrations. The `labels`, `annotations`, `nodeSelector` and
`tolerations` which the admission checks report in
`status.admissionChecks[].podSetUpdates` of a `Workload` are merged with the
flavors and applied to the pods of `SparkApplication` and `SparkCluster`. A
conflicting value fails the resource with `SchedulingFailure`, like a node
label conflict of the flavors.
### Why are the changes needed?
Previously, the operator applied only the flavors. So, the pods admitted
through a
[ProvisioningRequest](https://kueue.sigs.k8s.io/docs/concepts/admission_check/provisioning_request/)
admission check lacked its annotations and node selector, and were not bound
to the provisioned capacity.
### Does this PR introduce _any_ user-facing change?
No for the released versions, because the Kueue integration is not released
yet in
[1.0.0](https://github.com/apache/spark-kubernetes-operator/releases/tag/1.0.0)
(2026-07-23).
### How was this patch tested?
Pass the CIs with the newly added test cases.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5.5
Closes #951 from dongjoon-hyun/SPARK-60042.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
docs/spark_custom_resources.md | 16 ++
.../spark/k8s/operator/context/BaseContext.java | 26 +-
.../k8s/operator/context/SparkAppContext.java | 6 +-
.../k8s/operator/context/SparkClusterContext.java | 26 +-
...KueuePodSetFlavor.java => KueuePodSetInfo.java} | 56 +++-
.../k8s/operator/kueue/KueueWorkloadUtils.java | 218 ++++++++++-----
.../kueue/v1beta2/AdmissionCheckState.java | 45 ++++
.../k8s/operator/kueue/v1beta2/PodSetUpdate.java | 54 ++++
.../k8s/operator/kueue/v1beta2/WorkloadStatus.java | 3 +
.../reconciler/SparkAppResourceSpecFactory.java | 43 ++-
.../reconciler/reconcilesteps/AppInitStep.java | 2 +-
.../reconciler/reconcilesteps/ClusterInitStep.java | 2 +-
.../k8s/operator/context/SparkAppContextTest.java | 8 +-
.../operator/context/SparkClusterContextTest.java | 34 ++-
...SetFlavorTest.java => KueuePodSetInfoTest.java} | 68 ++++-
.../k8s/operator/kueue/KueueWorkloadUtilsTest.java | 299 ++++++++++++++++++---
.../k8s/operator/kueue/v1beta2/WorkloadTest.java | 45 ++++
.../SparkAppResourceSpecFactoryTest.java | 27 +-
.../reconciler/reconcilesteps/AppInitStepTest.java | 20 +-
.../reconcilesteps/ClusterInitStepTest.java | 12 +-
tests/e2e/kueue/chainsaw-test.yaml | 224 +++++++++++++++
.../spark-cluster-example-admission-check.yaml | 37 +++
tests/e2e/kueue/spark-example-admission-check.yaml | 35 +++
23 files changed, 1095 insertions(+), 211 deletions(-)
diff --git a/docs/spark_custom_resources.md b/docs/spark_custom_resources.md
index e5c19516..5ffcb9ac 100644
--- a/docs/spark_custom_resources.md
+++ b/docs/spark_custom_resources.md
@@ -716,6 +716,22 @@ spec:
cluster-scoped, so reading them needs the rules which
`operatorRbac.kueue.enabled` grants
through the ClusterRole, hence `operatorRbac.clusterRole.create` as well.
Until they can be
read, the resource is held and the read is retried.
+* Like Kueue built-in integrations, the `labels`, `annotations`,
`nodeSelector` and `tolerations`
+ which the [admission
checks](https://kueue.sigs.k8s.io/docs/concepts/admission_check/) of the
+ `Workload` report for a pod set in `status.admissionChecks[].podSetUpdates`
are added to its pods
+ as well, once all the checks are `Ready` and Kueue admits the `Workload`.
The executor pods get
+ them through the executor pod template, like the flavors. So a resource can
use a
+
[ProvisioningRequest](https://kueue.sigs.k8s.io/docs/concepts/admission_check/provisioning_request/)
+ admission check, whose `autoscaling.x-k8s.io/consume-provisioning-request`
and
+ `autoscaling.x-k8s.io/provisioning-class-name` annotations, and the node
selector which its
+ `ProvisioningRequestConfig` sets, bind the pods to the capacity provisioned
for them. The
+ operator reads them from the `Workload`, so they need no further RBAC rules.
Like Kueue, an
+ update must not change a value which the flavors or an earlier admission
check set, nor a value
+ of the node selector, labels or annotations of the pods, and such a conflict
fails the resource
+ like a node label conflict above. The labels and annotations which Spark
sets on the driver and
+ executor pods itself, e.g. `spark-role` or the ones of
+ `spark.kubernetes.{driver,executor}.{label,annotation}.*`, are not checked,
and Spark's value
+ wins over an update of the same key.
* `spec.suspend` takes precedence. A suspended resource does not get a
`Workload`, and suspending
a queued resource deletes its `Workload` to release the quota. Suspending a
running
`SparkApplication` deletes its `Workload` only after its driver and executor
pods are gone, or
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/BaseContext.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/BaseContext.java
index 312c7424..608a916e 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/BaseContext.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/BaseContext.java
@@ -32,7 +32,7 @@ import
io.javaoperatorsdk.operator.api.event.ResourceEventRecorder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import org.apache.spark.k8s.operator.BaseResource;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
import org.apache.spark.k8s.operator.utils.PodUtils;
@@ -46,8 +46,8 @@ public abstract class BaseContext<CR extends BaseResource<?,
?, ?, ?, ?>> {
/** The JOSDK context of the reconciliation this context belongs to. */
protected final Context<?> josdkContext;
- /** The flavors which Kueue assigned to the pod sets, applied to the
secondary resources. */
- protected Map<String, KueuePodSetFlavor> kueuePodSetFlavors = Map.of();
+ /** The KueuePodSetInfo by the pod set name, applied to the secondary
resources. */
+ protected Map<String, KueuePodSetInfo> kueuePodSetInfos = Map.of();
/** The ready pods by Spark role, see {@link #countReadyPodsByRole}. */
private Map<String, Long> readyPodsByRole;
@@ -69,24 +69,24 @@ public abstract class BaseContext<CR extends
BaseResource<?, ?, ?, ?, ?>> {
public abstract CR getResource();
/**
- * Sets the flavors which Kueue assigned to the pod sets of the resource, so
that its secondary
- * resources carry their node selectors and tolerations.
+ * Sets the KueuePodSetInfo which Kueue assigned to the pod sets of the
resource, so that its
+ * secondary resources carry their node selectors, tolerations, labels and
annotations.
*
- * @param kueuePodSetFlavors The KueuePodSetFlavor by the pod set name.
+ * @param kueuePodSetInfos The KueuePodSetInfo by the pod set name.
*/
- public void setKueuePodSetFlavors(Map<String, KueuePodSetFlavor>
kueuePodSetFlavors) {
+ public void setKueuePodSetInfos(Map<String, KueuePodSetInfo>
kueuePodSetInfos) {
synchronized (this) {
- this.kueuePodSetFlavors = kueuePodSetFlavors;
- applyKueuePodSetFlavors();
+ this.kueuePodSetInfos = kueuePodSetInfos;
+ applyKueuePodSetInfos();
}
}
/**
- * Applies the flavors to the secondary resource spec which was built before
them, e.g. to find
- * the driver pod or to check whether the master exists. Called while
holding the lock of this
- * context, so that a reader sees either spec with its flavors.
+ * Applies the KueuePodSetInfo to the secondary resource spec which was
built before it, e.g. to
+ * find the driver pod or to check whether the master exists. Called while
holding the lock of
+ * this context, so that a reader sees either spec with it.
*/
- protected abstract void applyKueuePodSetFlavors();
+ protected abstract void applyKueuePodSetInfos();
/**
* Returns the Kubernetes client associated with this context.
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkAppContext.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkAppContext.java
index 8030f9c4..cf1491a7 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkAppContext.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkAppContext.java
@@ -213,18 +213,18 @@ public class SparkAppContext extends
BaseContext<SparkApplication> {
if (secondaryResourceSpec == null) {
secondaryResourceSpec =
SparkAppResourceSpecFactory.buildResourceSpec(
- sparkApplication, josdkContext.getClient(), submissionWorker,
kueuePodSetFlavors);
+ sparkApplication, josdkContext.getClient(), submissionWorker,
kueuePodSetInfos);
}
return secondaryResourceSpec;
}
}
/**
- * Drops the resource spec built before the flavors, since they reach the
driver and the
+ * Drops the resource spec built before the KueuePodSetInfo, since it
reaches the driver and the
* executors through the pod templates which the submission builds the
resources from.
*/
@Override
- protected void applyKueuePodSetFlavors() {
+ protected void applyKueuePodSetInfos() {
// The caller holds this lock already, which is re-entrant, so that the
field is guarded by
// the same monitor as every other access to it.
synchronized (this) {
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkClusterContext.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkClusterContext.java
index 88502da9..0de8c4b7 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkClusterContext.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/context/SparkClusterContext.java
@@ -31,7 +31,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context;
import org.apache.spark.k8s.operator.SparkCluster;
import org.apache.spark.k8s.operator.SparkClusterResourceSpec;
import org.apache.spark.k8s.operator.SparkClusterSubmissionWorker;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadFactory;
import
org.apache.spark.k8s.operator.reconciler.SparkClusterResourceSpecFactory;
@@ -67,37 +67,37 @@ public class SparkClusterContext extends
BaseContext<SparkCluster> {
if (secondaryResourceSpec == null) {
secondaryResourceSpec =
SparkClusterResourceSpecFactory.buildResourceSpec(sparkCluster,
submissionWorker);
- applyKueuePodSetFlavors();
+ applyKueuePodSetInfos();
}
return secondaryResourceSpec;
}
}
/**
- * Adds the flavors to the StatefulSets of the resource spec, which is not
built again for them:
- * unlike the driver and the executors, the master and worker pods are
created from the pod
- * templates of these StatefulSets. Applying the same flavors twice is a
no-op, so the spec built
- * with them is left as it is.
+ * Adds the KueuePodSetInfo to the StatefulSets of the resource spec, which
is not built again for
+ * it: unlike the driver and the executors, the master and worker pods are
created from the pod
+ * templates of these StatefulSets. Applying the same KueuePodSetInfo twice
is a no-op, so the
+ * spec built with it is left as it is.
*/
@Override
- protected void applyKueuePodSetFlavors() {
+ protected void applyKueuePodSetInfos() {
// The caller holds this lock already, which is re-entrant, so that the
field is guarded by
// the same monitor as every other access to it.
synchronized (this) {
if (secondaryResourceSpec == null) {
return;
}
- applyKueuePodSetFlavor(
+ applyKueuePodSetInfo(
KueueWorkloadFactory.PODSET_MASTER,
secondaryResourceSpec.getMasterStatefulSet());
- applyKueuePodSetFlavor(
+ applyKueuePodSetInfo(
KueueWorkloadFactory.PODSET_WORKER,
secondaryResourceSpec.getWorkerStatefulSet());
}
}
- private void applyKueuePodSetFlavor(String podSetName, StatefulSet
statefulSet) {
- KueuePodSetFlavor flavor = kueuePodSetFlavors.get(podSetName);
- if (flavor != null) {
- flavor.applyTo(statefulSet.getSpec().getTemplate().getSpec());
+ private void applyKueuePodSetInfo(String podSetName, StatefulSet
statefulSet) {
+ KueuePodSetInfo podSetInfo = kueuePodSetInfos.get(podSetName);
+ if (podSetInfo != null) {
+ podSetInfo.applyTo(statefulSet.getSpec().getTemplate());
}
}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueuePodSetFlavor.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueuePodSetInfo.java
similarity index 61%
rename from
spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueuePodSetFlavor.java
rename to
spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueuePodSetInfo.java
index 0019f77c..c2afd2aa 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueuePodSetFlavor.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueuePodSetInfo.java
@@ -25,27 +25,69 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.PodSpec;
+import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.api.model.Toleration;
/**
- * The node selector and tolerations of the ResourceFlavors which Kueue
assigned to a pod set.
- * Like Kueue built-in integrations, they are applied to the pods of the pod
set.
+ * The node selector, tolerations, labels and annotations which Kueue assigned
to a pod set, like
+ * Kueue's `podset.PodSetInfo`: the ones of its ResourceFlavors and of the
podSetUpdates of the
+ * admission checks of its Workload. Like Kueue built-in integrations, they
are applied to the pods
+ * of the pod set.
*
- * @param nodeSelector The merged `nodeLabels` of the ResourceFlavors.
- * @param tolerations The merged `tolerations` of the ResourceFlavors.
+ * @param nodeSelector The merged `nodeLabels` of the ResourceFlavors and
`nodeSelector` of the
+ * podSetUpdates.
+ * @param tolerations The merged `tolerations` of the ResourceFlavors and the
podSetUpdates.
+ * @param labels The merged `labels` of the podSetUpdates.
+ * @param annotations The merged `annotations` of the podSetUpdates.
*/
-public record KueuePodSetFlavor(Map<String, String> nodeSelector,
List<Toleration> tolerations) {
+public record KueuePodSetInfo(
+ Map<String, String> nodeSelector,
+ List<Toleration> tolerations,
+ Map<String, String> labels,
+ Map<String, String> annotations) {
private static final String OPERATOR_EQUAL = "Equal";
+ /**
+ * Creates the KueuePodSetInfo of ResourceFlavors only, which have neither
labels nor
+ * annotations.
+ *
+ * @param nodeSelector The merged `nodeLabels` of the ResourceFlavors.
+ * @param tolerations The merged `tolerations` of the ResourceFlavors.
+ */
+ public KueuePodSetInfo(Map<String, String> nodeSelector, List<Toleration>
tolerations) {
+ this(nodeSelector, tolerations, Map.of(), Map.of());
+ }
+
+ /**
+ * Adds the labels and annotations to the metadata of the given pod
template, and the node
+ * selector and tolerations to its pod spec. Missing metadata or a missing
pod spec is created. A
+ * conflict with the pod template must be checked beforehand, as {@link
+ * KueueWorkloadUtils#holdForAdmission} does.
+ *
+ * @param template The pod template to be modified in place.
+ */
+ public void applyTo(final PodTemplateSpec template) {
+ template.setMetadata(
+ new ObjectMetaBuilder(template.getMetadata())
+ .addToLabels(labels)
+ .addToAnnotations(annotations)
+ .build());
+ if (template.getSpec() == null) {
+ template.setSpec(new PodSpec());
+ }
+ applyTo(template.getSpec());
+ }
+
/**
* Adds the node selector and tolerations to the given pod spec. A node
selector conflict must be
- * checked beforehand, see {@link KueueWorkloadUtils#resolvePodSetFlavors}.
+ * checked beforehand, see {@link KueueWorkloadUtils#resolvePodSetInfos}.
*
* @param podSpec The pod spec to be modified in place.
*/
- public void applyTo(final PodSpec podSpec) {
+ void applyTo(final PodSpec podSpec) {
Map<String, String> mergedNodeSelector = new HashMap<>();
if (podSpec.getNodeSelector() != null) {
mergedNodeSelector.putAll(podSpec.getNodeSelector());
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
index 0e335a93..482fdba1 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
@@ -48,7 +48,10 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import io.fabric8.kubernetes.api.model.Condition;
import io.fabric8.kubernetes.api.model.ConditionBuilder;
import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.api.model.OwnerReference;
+import io.fabric8.kubernetes.api.model.PodSpec;
+import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.api.model.Toleration;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientException;
@@ -57,8 +60,10 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.spark.k8s.operator.Constants;
import org.apache.spark.k8s.operator.client.KubernetesClientFactory;
import org.apache.spark.k8s.operator.context.BaseContext;
+import org.apache.spark.k8s.operator.kueue.v1beta2.AdmissionCheckState;
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSet;
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSetAssignment;
+import org.apache.spark.k8s.operator.kueue.v1beta2.PodSetUpdate;
import org.apache.spark.k8s.operator.kueue.v1beta2.PriorityClassRef;
import org.apache.spark.k8s.operator.kueue.v1beta2.ResourceFlavor;
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
@@ -252,7 +257,8 @@ public final class KueueWorkloadUtils {
* queued. An API failure of the request is retried rather than failing the
resource: a transient
* one shortly, a persistent one with the default interval, so that its
event is not rewritten
* every few seconds until a user fixes the cause. Once admitted, the
flavors which Kueue
- * assigned are set to the context, so that the secondary resources are
built with them.
+ * assigned, with the pod set updates of the admission checks, are set to
the context, so that
+ * the secondary resources are built with them.
*
* @param context The context of the resource to be admitted.
* @param desired The Workload built for the resource. The pod sets hash
annotation is added to it
@@ -334,16 +340,16 @@ public final class KueueWorkloadUtils {
requested);
return Optional.of(ReconcileProgress.completeAndDefaultRequeue());
}
- Map<String, KueuePodSetFlavor> flavors;
+ Map<String, KueuePodSetInfo> podSetInfos;
try {
- flavors = resolvePodSetFlavors(context.getClient(),
admission.workload());
- checkNoNodeSelectorConflict(flavors, desired);
+ podSetInfos = resolvePodSetInfos(context.getClient(),
admission.workload());
+ checkNoPodTemplateConflict(podSetInfos, desired);
} catch (KubernetesClientException e) {
return Optional.of(retryAfterFlavorReadFailure(context, e,
workloadName));
} catch (IllegalArgumentException e) {
- // Like Kueue, a node selector conflict is permanent, so the quota is
released before the
- // caller fails the resource, which this step does not reach again. The
release itself is
- // retried, since a Workload left behind holds the quota of a resource
that never starts.
+ // Like Kueue, a conflict is permanent, so the quota is released before
the caller fails the
+ // resource, which this step does not reach again. The release itself is
retried, since a
+ // Workload left behind holds the quota of a resource that never starts.
Optional<ReconcileProgress> retry =
releaseOrRetry(context, "Failed to release the Kueue Workload of a
rejected admission");
if (retry.isPresent()) {
@@ -355,22 +361,23 @@ public final class KueueWorkloadUtils {
context.getEventRecorder(),
EventUtils.REASON_KUEUE_ADMITTED,
"Kueue admitted Workload " + workloadName + ", requesting " +
requested + ".");
- context.setKueuePodSetFlavors(flavors);
+ context.setKueuePodSetInfos(podSetInfos);
return Optional.empty();
}
/**
- * Sets the flavors of the Workload which Kueue admitted before on the
context of a resource
- * whose driver or master exists already. Such a reconcile applies the
secondary resources again
- * without requesting the admission, so rebuilding them without the flavors
would remove the node
- * selector and the tolerations which Kueue assigned to the pods.
+ * Sets the KueuePodSetInfo of the Workload which Kueue admitted before on
the context of a
+ * resource whose driver or master exists already. Such a reconcile applies
the secondary
+ * resources again without requesting the admission, so rebuilding them
without it would remove
+ * the node selector, the tolerations, the labels and the annotations which
Kueue assigned to the
+ * pods.
*
* @param context The context of the resource to be reconciled.
* @return The progress to return while the flavors cannot be read, or empty
to proceed.
- * @throws IllegalArgumentException if a node label of the flavors conflicts
with the node
- * selector of a pod set, like {@link #holdForAdmission}.
+ * @throws IllegalArgumentException if the KueuePodSetInfo conflicts with a
pod set, like {@link
+ * #holdForAdmission}.
*/
- public static Optional<ReconcileProgress> applyAdmittedFlavors(final
BaseContext<?> context) {
+ public static Optional<ReconcileProgress> applyAdmittedPodSetInfos(final
BaseContext<?> context) {
HasMetadata resource = context.getResource();
String workloadName = KueueWorkloadFactory.getWorkloadName(resource);
Workload workload;
@@ -392,12 +399,12 @@ public final class KueueWorkloadUtils {
return Optional.empty();
}
try {
- Map<String, KueuePodSetFlavor> flavors =
resolvePodSetFlavors(context.getClient(), workload);
+ Map<String, KueuePodSetInfo> podSetInfos =
resolvePodSetInfos(context.getClient(), workload);
// The pod set templates of the Workload hold the node selectors as of
the admission, so the
// check reports a ResourceFlavor which was edited since. Unlike the
admission, the quota is
// not released with the failure: the pods it was reserved for are
running already.
- checkNoNodeSelectorConflict(flavors, workload);
- context.setKueuePodSetFlavors(flavors);
+ checkNoPodTemplateConflict(podSetInfos, workload);
+ context.setKueuePodSetInfos(podSetInfos);
} catch (KubernetesClientException e) {
return Optional.of(retryAfterFlavorReadFailure(context, e,
workloadName));
}
@@ -461,7 +468,7 @@ public final class KueueWorkloadUtils {
* into quota which nothing uses. An admitted Workload is kept, since the
resources it was
* admitted for may be running already, and it is released with them like
the Workload of a
* queued resource. If they were requested already, its flavors are applied
again like {@link
- * #applyAdmittedFlavors}, since the secondary resources are applied again
in this reconcile.
+ * #applyAdmittedPodSetInfos}, since the secondary resources are applied
again in this reconcile.
* Otherwise they start without Kueue, so the flavors are not read, which
must not hold them
* back, and an evicted or deactivated Workload is released first rather
than kept for resources
* whose quota Kueue is about to take or no longer counts. Like the
admission request, a failed
@@ -473,9 +480,8 @@ public final class KueueWorkloadUtils {
* an admitted Workload, so that a resource without one costs no lookup.
* @return The progress to return while the release or the read of the
flavors fails, or empty to
* proceed.
- * @throws IllegalArgumentException if a node label of the flavors of an
admitted Workload
- * conflicts with the node selector of a pod set of the requested
resources, like {@link
- * #applyAdmittedFlavors}.
+ * @throws IllegalArgumentException if the flavors of an admitted Workload
conflict with a pod set
+ * of the requested resources, like {@link #applyAdmittedPodSetInfos}.
*/
public static Optional<ReconcileProgress> handleDequeuedWorkload(
final BaseContext<?> context, final BooleanSupplier requested) {
@@ -485,7 +491,7 @@ public final class KueueWorkloadUtils {
}
if (isAdmitted(workload.get())) {
if (requested.getAsBoolean()) {
- return applyAdmittedFlavors(context);
+ return applyAdmittedPodSetInfos(context);
}
if (findEviction(workload.get()).isEmpty() &&
!isDeactivated(workload.get())) {
return Optional.empty();
@@ -541,24 +547,35 @@ public final class KueueWorkloadUtils {
/**
* Resolves the node selector and tolerations of the ResourceFlavors which
Kueue assigned to each
- * pod set of the admitted Workload, like Kueue's `podset.FromAssignment`.
The Topology Aware
- * Scheduling gate and annotation are not handled. The flavors of a pod set
are applied in the
- * order of the resource names, so that a later flavor overwrites a node
label deterministically.
+ * pod set of the admitted Workload, like Kueue's `podset.FromAssignment`,
and merges the
+ * podSetUpdates of its admission checks into them, like Kueue's
`podset.FromUpdate`. The Topology
+ * Aware Scheduling gate and annotation are not handled. The flavors of a
pod set are applied in
+ * the order of the resource names, so that a later flavor overwrites a node
label
+ * deterministically. Like Kueue built-in integrations, each admission check
adds its first
+ * podSetUpdate of the pod set, in the order of `status.admissionChecks`,
which Kueue keeps sorted
+ * by name. Their state is not checked, since Kueue admits a Workload only
once all of its
+ * admission checks are ready.
*
* @param client The KubernetesClient.
* @param admitted The admitted Workload.
- * @return The KueuePodSetFlavor by the pod set name. A pod set without an
assignment, or one
- * whose flavors have neither node labels nor tolerations, is absent, so
that the resources
- * of such a pod set are built as they are without Kueue.
+ * @return The KueuePodSetInfo by the pod set name. A pod set without an
assignment, or one to
+ * which neither its flavors nor the podSetUpdates add anything, is
absent, so that the
+ * resources of such a pod set are built as they are without Kueue.
* @throws KubernetesClientException if a ResourceFlavor cannot be read.
+ * @throws IllegalArgumentException if a podSetUpdate changes a value of the
node selector, the
+ * labels or the annotations which the flavors or an earlier admission
check assigned to the
+ * pod set. Like Kueue built-in integrations, this is permanent. A
conflict with the pod
+ * templates is checked separately, like Kueue's `podset.Merge`.
*/
- public static Map<String, KueuePodSetFlavor> resolvePodSetFlavors(
+ public static Map<String, KueuePodSetInfo> resolvePodSetInfos(
final KubernetesClient client, final Workload admitted) {
- Map<String, KueuePodSetFlavor> result = new HashMap<>();
+ Map<String, KueuePodSetInfo> result = new HashMap<>();
WorkloadStatus status = admitted.getStatus();
if (status == null || status.getAdmission() == null) {
return result;
}
+ List<AdmissionCheckState> admissionChecks =
+ status.getAdmissionChecks() == null ? List.of() :
status.getAdmissionChecks();
Map<String, ResourceFlavor> flavorCache = new HashMap<>();
for (PodSetAssignment assignment :
status.getAdmission().getPodSetAssignments()) {
Map<String, String> nodeSelector = new HashMap<>();
@@ -574,18 +591,91 @@ public final class KueueWorkloadUtils {
nodeSelector.putAll(flavor.getSpec().getNodeLabels());
}
if (flavor.getSpec().getTolerations() != null) {
- KueuePodSetFlavor.addTolerations(tolerations,
flavor.getSpec().getTolerations());
+ KueuePodSetInfo.addTolerations(tolerations,
flavor.getSpec().getTolerations());
}
}
}
- if (nodeSelector.isEmpty() && tolerations.isEmpty()) {
+ Map<String, String> labels = new HashMap<>();
+ Map<String, String> annotations = new HashMap<>();
+ String podSetName = assignment.getName();
+ for (AdmissionCheckState check : admissionChecks) {
+ Optional<PodSetUpdate> found = findPodSetUpdate(check, podSetName);
+ if (found.isEmpty()) {
+ continue;
+ }
+ PodSetUpdate update = found.get();
+ String conflict =
+ "Kueue admission check "
+ + check.getName()
+ + " conflicts with the ResourceFlavors or an earlier admission
check of the "
+ + podSetName
+ + " pods for the ";
+ putAllWithoutConflict(nodeSelector, update.getNodeSelector(), conflict
+ "node selector");
+ putAllWithoutConflict(labels, update.getLabels(), conflict + "labels");
+ putAllWithoutConflict(annotations, update.getAnnotations(), conflict +
"annotations");
+ if (update.getTolerations() != null) {
+ KueuePodSetInfo.addTolerations(tolerations, update.getTolerations());
+ }
+ }
+ if (nodeSelector.isEmpty()
+ && tolerations.isEmpty()
+ && labels.isEmpty()
+ && annotations.isEmpty()) {
continue;
}
- result.put(assignment.getName(), new KueuePodSetFlavor(nodeSelector,
tolerations));
+ result.put(
+ podSetName, new KueuePodSetInfo(nodeSelector, tolerations, labels,
annotations));
}
return result;
}
+ /** Like Kueue, only the first podSetUpdate of a pod set in an admission
check is used. */
+ private static Optional<PodSetUpdate> findPodSetUpdate(
+ final AdmissionCheckState check, final String podSetName) {
+ if (check.getPodSetUpdates() == null) {
+ return Optional.empty();
+ }
+ return check.getPodSetUpdates().stream()
+ .filter(update -> podSetName.equals(update.getName()))
+ .findFirst();
+ }
+
+ /**
+ * Like Kueue's `PodSetInfo.Merge`, adds the values of a podSetUpdate to the
ones which the
+ * flavors and the earlier admission checks assigned to a pod set, which it
must not change.
+ */
+ private static void putAllWithoutConflict(
+ final Map<String, String> assigned,
+ final Map<String, String> updated,
+ final String conflict) {
+ checkNoConflict(conflict, assigned, updated);
+ if (updated != null) {
+ assigned.putAll(updated);
+ }
+ }
+
+ /**
+ * Like Kueue's `maps.HaveConflict`, a key which both maps have must have
the same value.
+ *
+ * @param conflict The start of the message, which names what conflicts.
+ * @param existing The values before the merge, reported as `value1`.
+ * @param added The values to be merged, reported as `value2`.
+ * @throws IllegalArgumentException if a key of both maps has different
values.
+ */
+ private static void checkNoConflict(
+ final String conflict, final Map<String, String> existing, final
Map<String, String> added) {
+ if (existing == null || added == null) {
+ return;
+ }
+ for (Map.Entry<String, String> e : existing.entrySet()) {
+ String value = added.get(e.getKey());
+ if (value != null && !value.equals(e.getValue())) {
+ throw new IllegalArgumentException(
+ conflict + " key=" + e.getKey() + ", value1=" + e.getValue() + ",
value2=" + value);
+ }
+ }
+ }
+
private static ResourceFlavor getResourceFlavor(
final KubernetesClient client, final String name) {
ResourceFlavor flavor =
client.resources(ResourceFlavor.class).withName(name).get();
@@ -596,54 +686,40 @@ public final class KueueWorkloadUtils {
return flavor;
}
- private static Map<String, String> podSetNodeSelector(
- final Workload workload, final String podSetName) {
+ private static PodTemplateSpec podSetTemplate(final Workload workload, final
String podSetName) {
return workload.getSpec().getPodSets().stream()
.filter(podSet -> podSetName.equals(podSet.getName()))
.map(PodSet::getTemplate)
- .filter(template -> template != null && template.getSpec() != null)
- .map(template -> template.getSpec().getNodeSelector())
.filter(Objects::nonNull)
.findFirst()
- .orElse(Map.of());
+ .orElseGet(PodTemplateSpec::new);
}
/**
- * Like Kueue's podset.Merge, a node label must not change the node selector
of the pods.
+ * Like Kueue's podset.Merge, the node selector, labels and annotations
which Kueue assigned must
+ * not change the ones of the pods.
*
- * @param flavors The resolved flavors by the pod set name.
+ * @param podSetInfos The resolved KueuePodSetInfo by the pod set name.
* @param desired The Workload built for the resource, whose pod set
templates have the node
- * selectors of the pods.
- * @throws IllegalArgumentException if a node label of the flavors conflicts
with the node
- * selector of a pod set. Like Kueue built-in integrations, this is
permanent.
+ * selectors, labels and annotations of the pods.
+ * @throws IllegalArgumentException if a value of a KueuePodSetInfo
conflicts with a pod set
+ * template. Like Kueue built-in integrations, this is permanent.
*/
- static void checkNoNodeSelectorConflict(
- final Map<String, KueuePodSetFlavor> flavors, final Workload desired) {
- for (Map.Entry<String, KueuePodSetFlavor> flavor : flavors.entrySet()) {
- checkNoNodeSelectorConflict(
- flavor.getKey(),
- podSetNodeSelector(desired, flavor.getKey()),
- flavor.getValue().nodeSelector());
- }
- }
-
- private static void checkNoNodeSelectorConflict(
- final String podSetName,
- final Map<String, String> podNodeSelector,
- final Map<String, String> flavorNodeLabels) {
- for (Map.Entry<String, String> e : podNodeSelector.entrySet()) {
- String flavorValue = flavorNodeLabels.get(e.getKey());
- if (flavorValue != null && !flavorValue.equals(e.getValue())) {
- throw new IllegalArgumentException(
- "The node labels of the Kueue ResourceFlavors conflict with the
node selector of the "
- + podSetName
- + " pods for key="
- + e.getKey()
- + ", value1="
- + e.getValue()
- + ", value2="
- + flavorValue);
- }
+ static void checkNoPodTemplateConflict(
+ final Map<String, KueuePodSetInfo> podSetInfos, final Workload desired) {
+ for (Map.Entry<String, KueuePodSetInfo> entry : podSetInfos.entrySet()) {
+ PodTemplateSpec template = podSetTemplate(desired, entry.getKey());
+ ObjectMeta metadata =
+ template.getMetadata() == null ? new ObjectMeta() :
template.getMetadata();
+ PodSpec spec = template.getSpec() == null ? new PodSpec() :
template.getSpec();
+ KueuePodSetInfo info = entry.getValue();
+ String conflict =
+ "The Kueue ResourceFlavors and admission checks conflict with the "
+ + entry.getKey()
+ + " pods for the ";
+ checkNoConflict(conflict + "node selector", spec.getNodeSelector(),
info.nodeSelector());
+ checkNoConflict(conflict + "labels", metadata.getLabels(),
info.labels());
+ checkNoConflict(conflict + "annotations", metadata.getAnnotations(),
info.annotations());
}
}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/AdmissionCheckState.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/AdmissionCheckState.java
new file mode 100644
index 00000000..10a6c9d9
--- /dev/null
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/AdmissionCheckState.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.spark.k8s.operator.kueue.v1beta2;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * AdmissionCheckState is the state of an admission check of a Workload, which
the controller of
+ * the check reports, e.g. with the pod set updates of a ProvisioningRequest
admission check.
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class AdmissionCheckState {
+ private String name;
+ @Builder.Default
+ private List<PodSetUpdate> podSetUpdates = new ArrayList<>();
+}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/PodSetUpdate.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/PodSetUpdate.java
new file mode 100644
index 00000000..49b10155
--- /dev/null
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/PodSetUpdate.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.spark.k8s.operator.kueue.v1beta2;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.fabric8.kubernetes.api.model.Toleration;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * PodSetUpdate is a modification of the pods of a pod set which an admission
check suggests, e.g.
+ * the annotations which let the pods consume the capacity of a
ProvisioningRequest.
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PodSetUpdate {
+ private String name;
+ @Builder.Default
+ private Map<String, String> labels = new HashMap<>();
+ @Builder.Default
+ private Map<String, String> annotations = new HashMap<>();
+ @Builder.Default
+ private Map<String, String> nodeSelector = new HashMap<>();
+ @Builder.Default
+ private List<Toleration> tolerations = new ArrayList<>();
+}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadStatus.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadStatus.java
index 90f6f478..0aea7c1a 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadStatus.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadStatus.java
@@ -45,6 +45,9 @@ public class WorkloadStatus {
private List<Condition> conditions = new ArrayList<>();
private Admission admission;
private RequeueState requeueState;
+ // Without a default, so that a status which the operator creates to record
a condition does not
+ // write the admission checks, which Kueue owns.
+ private List<AdmissionCheckState> admissionChecks;
/**
* Checks if the workload has been admitted by Kueue.
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactory.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactory.java
index 53badf6e..03f036fd 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactory.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactory.java
@@ -35,7 +35,6 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
-import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
import io.fabric8.kubernetes.client.KubernetesClient;
import lombok.extern.slf4j.Slf4j;
@@ -45,7 +44,7 @@ import org.apache.spark.k8s.operator.SparkAppSubmissionWorker;
import org.apache.spark.k8s.operator.SparkApplication;
import org.apache.spark.k8s.operator.decorators.OwnerResourceDecorator;
import org.apache.spark.k8s.operator.decorators.SecurityContextDecorator;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadFactory;
import org.apache.spark.k8s.operator.spec.ApplicationSpec;
import org.apache.spark.k8s.operator.spec.BaseApplicationTemplateSpec;
@@ -59,23 +58,23 @@ public final class SparkAppResourceSpecFactory {
private SparkAppResourceSpecFactory() {}
/**
- * Builds a SparkAppResourceSpec for the given SparkApplication with the
flavors which Kueue
- * assigned to the driver and executor pod sets. The flavors are applied to
the pod templates,
- * because the executor pods are created by the driver.
+ * Builds a SparkAppResourceSpec for the given SparkApplication with the
KueuePodSetInfo which
+ * Kueue assigned to the driver and executor pod sets. It is applied to the
pod templates, because
+ * the executor pods are created by the driver.
*
* @param app The SparkApplication.
* @param client The KubernetesClient.
* @param worker The SparkAppSubmissionWorker.
- * @param kueuePodSetFlavors The KueuePodSetFlavor by the pod set name.
+ * @param kueuePodSetInfos The KueuePodSetInfo by the pod set name.
* @return A SparkAppResourceSpec containing the configured resources.
*/
public static SparkAppResourceSpec buildResourceSpec(
final SparkApplication app,
final KubernetesClient client,
final SparkAppSubmissionWorker worker,
- final Map<String, KueuePodSetFlavor> kueuePodSetFlavors) {
+ final Map<String, KueuePodSetInfo> kueuePodSetInfos) {
SparkApplication appToSubmit =
- kueuePodSetFlavors.isEmpty() ? app : applyKueuePodSetFlavors(app,
kueuePodSetFlavors);
+ kueuePodSetInfos.isEmpty() ? app : applyKueuePodSetInfos(app,
kueuePodSetInfos);
Map<String, String> confOverrides = overrideDependencyConf(appToSubmit);
SparkAppResourceSpec resourceSpec = worker.getResourceSpec(appToSubmit,
client, confOverrides);
cleanUpTempResourcesForApp(appToSubmit, confOverrides);
@@ -87,35 +86,31 @@ public final class SparkAppResourceSpecFactory {
/**
* Returns a copy of the SparkApplication whose driver and executor pod
templates have the
- * flavors, so that the SparkApplication and its Kueue Workload are not
changed.
+ * KueuePodSetInfo, so that the SparkApplication and its Kueue Workload are
not changed.
*/
- private static SparkApplication applyKueuePodSetFlavors(
- final SparkApplication app, final Map<String, KueuePodSetFlavor>
kueuePodSetFlavors) {
+ private static SparkApplication applyKueuePodSetInfos(
+ final SparkApplication app, final Map<String, KueuePodSetInfo>
kueuePodSetInfos) {
SparkApplication copy = ReconcilerUtils.clone(app);
ApplicationSpec spec = copy.getSpec();
- KueuePodSetFlavor driverFlavor =
kueuePodSetFlavors.get(KueueWorkloadFactory.PODSET_DRIVER);
- if (driverFlavor != null) {
- spec.setDriverSpec(applyKueuePodSetFlavor(spec.getDriverSpec(),
driverFlavor));
+ KueuePodSetInfo driverInfo =
kueuePodSetInfos.get(KueueWorkloadFactory.PODSET_DRIVER);
+ if (driverInfo != null) {
+ spec.setDriverSpec(applyKueuePodSetInfo(spec.getDriverSpec(),
driverInfo));
}
- KueuePodSetFlavor executorFlavor =
- kueuePodSetFlavors.get(KueueWorkloadFactory.PODSET_EXECUTOR);
- if (executorFlavor != null) {
- spec.setExecutorSpec(applyKueuePodSetFlavor(spec.getExecutorSpec(),
executorFlavor));
+ KueuePodSetInfo executorInfo =
kueuePodSetInfos.get(KueueWorkloadFactory.PODSET_EXECUTOR);
+ if (executorInfo != null) {
+ spec.setExecutorSpec(applyKueuePodSetInfo(spec.getExecutorSpec(),
executorInfo));
}
return copy;
}
- private static BaseApplicationTemplateSpec applyKueuePodSetFlavor(
- final BaseApplicationTemplateSpec templateSpec, final KueuePodSetFlavor
flavor) {
+ private static BaseApplicationTemplateSpec applyKueuePodSetInfo(
+ final BaseApplicationTemplateSpec templateSpec, final KueuePodSetInfo
podSetInfo) {
BaseApplicationTemplateSpec result =
templateSpec == null ? new BaseApplicationTemplateSpec() :
templateSpec;
if (result.getPodTemplateSpec() == null) {
result.setPodTemplateSpec(new PodTemplateSpec());
}
- if (result.getPodTemplateSpec().getSpec() == null) {
- result.getPodTemplateSpec().setSpec(new PodSpec());
- }
- flavor.applyTo(result.getPodTemplateSpec().getSpec());
+ podSetInfo.applyTo(result.getPodTemplateSpec());
return result;
}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
index e03851ec..c2e9ab1c 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
@@ -217,7 +217,7 @@ public final class AppInitStep extends AppReconcileStep {
if (driverRequested || isDriverRequested(context)) {
// The driver resources are applied again in this reconcile, so the
flavors of the Workload
// which was admitted before are resolved again instead of dropping
them from the resources.
- return KueueWorkloadUtils.applyAdmittedFlavors(context);
+ return KueueWorkloadUtils.applyAdmittedPodSetInfos(context);
}
} catch (KubernetesClientException e) {
// Requesting the admission of a driver which is already running would
be wrong, so the
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
index 2f5f399b..d5eb1859 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
@@ -240,7 +240,7 @@ public final class ClusterInitStep extends
ClusterReconcileStep {
// The master and worker StatefulSets are applied again in this
reconcile, so the flavors
// of the Workload which was admitted before are resolved again
instead of dropping them
// from the pod templates.
- return KueueWorkloadUtils.applyAdmittedFlavors(context);
+ return KueueWorkloadUtils.applyAdmittedPodSetInfos(context);
}
} catch (KubernetesClientException e) {
// Requesting the admission of a master which is already running would
be wrong, so the
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkAppContextTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkAppContextTest.java
index 46688fc5..03343b26 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkAppContextTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkAppContextTest.java
@@ -59,7 +59,7 @@ import org.apache.spark.k8s.operator.Constants;
import org.apache.spark.k8s.operator.SparkAppResourceSpec;
import org.apache.spark.k8s.operator.SparkAppSubmissionWorker;
import org.apache.spark.k8s.operator.SparkApplication;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
class SparkAppContextTest {
private final SparkApplication application = buildApplication();
@@ -214,7 +214,7 @@ class SparkAppContextTest {
}
@Test
- void kueuePodSetFlavorsRebuildTheCachedResourceSpec() {
+ void kueuePodSetInfosRebuildTheCachedResourceSpec() {
// The driver pod spec can be built before the Kueue admission, e.g. to
find the driver pod of
// the current attempt, so setting the flavors drops the cached one.
KubernetesClient client = mock(KubernetesClient.class);
@@ -231,8 +231,8 @@ class SparkAppContextTest {
verify(worker, times(1)).getResourceSpec(any(), any(), any());
Toleration toleration = new Toleration("NoSchedule", "spot", "Exists",
null, null);
- context.setKueuePodSetFlavors(
- Map.of("driver", new KueuePodSetFlavor(Map.of("pool", "cpu"),
List.of(toleration))));
+ context.setKueuePodSetInfos(
+ Map.of("driver", new KueuePodSetInfo(Map.of("pool", "cpu"),
List.of(toleration))));
context.getDriverPodSpec();
ArgumentCaptor<SparkApplication> captor =
ArgumentCaptor.forClass(SparkApplication.class);
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkClusterContextTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkClusterContextTest.java
index 57de9004..2806f771 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkClusterContextTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/context/SparkClusterContextTest.java
@@ -30,6 +30,7 @@ import java.util.Optional;
import java.util.stream.Stream;
import com.fasterxml.jackson.core.JsonProcessingException;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.PodBuilder;
@@ -44,7 +45,7 @@ import org.apache.spark.k8s.operator.Constants;
import org.apache.spark.k8s.operator.SparkCluster;
import org.apache.spark.k8s.operator.SparkClusterSubmissionWorker;
import org.apache.spark.k8s.operator.config.SparkOperatorConf;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadFactory;
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
import org.apache.spark.k8s.operator.spec.ClusterSpec;
@@ -57,7 +58,7 @@ import org.apache.spark.k8s.operator.utils.TestUtils;
class SparkClusterContextTest {
@Test
- void kueuePodSetFlavorsAreAppliedToStatefulSets() {
+ void kueuePodSetInfosAreAppliedToStatefulSets() {
SparkCluster cluster = buildCluster();
String clusterSpec = asJson(cluster.getSpec());
SparkClusterContext context =
@@ -69,12 +70,20 @@ class SparkClusterContextTest {
Assertions.assertEquals(
List.of(),
podSpec(context.getMasterStatefulSetSpec()).getTolerations());
- context.setKueuePodSetFlavors(
+ context.setKueuePodSetInfos(
Map.of(
KueueWorkloadFactory.PODSET_MASTER,
- new KueuePodSetFlavor(Map.of("pool", "cpu"), List.of(spot)),
+ new KueuePodSetInfo(
+ Map.of("pool", "cpu"),
+ List.of(spot),
+ Map.of("team", "a"),
+ Map.of("provisioning", "pr-master")),
KueueWorkloadFactory.PODSET_WORKER,
- new KueuePodSetFlavor(Map.of("pool", "gpu"), List.of(gpu))));
+ new KueuePodSetInfo(
+ Map.of("pool", "gpu"),
+ List.of(gpu),
+ Map.of("team", "a"),
+ Map.of("provisioning", "pr-worker"))));
PodSpec master = podSpec(context.getMasterStatefulSetSpec());
Assertions.assertEquals(Map.of("pool", "cpu"), master.getNodeSelector());
@@ -82,6 +91,21 @@ class SparkClusterContextTest {
PodSpec worker = podSpec(context.getWorkerStatefulSetSpec());
Assertions.assertEquals(Map.of("pool", "gpu"), worker.getNodeSelector());
Assertions.assertEquals(List.of(gpu), worker.getTolerations());
+ // The labels which the StatefulSets select their pods with are kept
+ ObjectMeta masterMetadata =
+
context.getMasterStatefulSetSpec().getSpec().getTemplate().getMetadata();
+ Assertions.assertEquals("a", masterMetadata.getLabels().get("team"));
+ Assertions.assertEquals("pr-master",
masterMetadata.getAnnotations().get("provisioning"));
+ Assertions.assertEquals(
+ Constants.LABEL_SPARK_ROLE_MASTER_VALUE,
+ masterMetadata.getLabels().get(Constants.LABEL_SPARK_ROLE_NAME));
+ ObjectMeta workerMetadata =
+
context.getWorkerStatefulSetSpec().getSpec().getTemplate().getMetadata();
+ Assertions.assertEquals("a", workerMetadata.getLabels().get("team"));
+ Assertions.assertEquals("pr-worker",
workerMetadata.getAnnotations().get("provisioning"));
+ Assertions.assertEquals(
+ Constants.LABEL_SPARK_ROLE_WORKER_VALUE,
+ workerMetadata.getLabels().get(Constants.LABEL_SPARK_ROLE_NAME));
// The flavors are applied to the built StatefulSets only, so the
SparkCluster keeps the pod
// templates which the pod sets of its Kueue Workload are hashed from
Assertions.assertEquals(clusterSpec, asJson(cluster.getSpec()));
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueuePodSetFlavorTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueuePodSetInfoTest.java
similarity index 60%
rename from
spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueuePodSetFlavorTest.java
rename to
spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueuePodSetInfoTest.java
index 3f29f1b5..2bd996af 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueuePodSetFlavorTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueuePodSetInfoTest.java
@@ -28,17 +28,19 @@ import java.util.Map;
import io.fabric8.kubernetes.api.model.PodSpec;
import io.fabric8.kubernetes.api.model.PodSpecBuilder;
+import io.fabric8.kubernetes.api.model.PodTemplateSpec;
+import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder;
import io.fabric8.kubernetes.api.model.Toleration;
import org.junit.jupiter.api.Test;
-class KueuePodSetFlavorTest {
+class KueuePodSetInfoTest {
@Test
void applyToAddsNodeSelectorAndTolerationsToEmptyPodSpec() {
PodSpec podSpec = new PodSpec();
Toleration toleration = toleration("spot", "Equal", "true", "NoSchedule",
null);
- new KueuePodSetFlavor(Map.of("instance-type", "spot"),
List.of(toleration)).applyTo(podSpec);
+ new KueuePodSetInfo(Map.of("instance-type", "spot"),
List.of(toleration)).applyTo(podSpec);
assertEquals(Map.of("instance-type", "spot"), podSpec.getNodeSelector());
assertEquals(List.of(toleration), podSpec.getTolerations());
@@ -54,7 +56,7 @@ class KueuePodSetFlavorTest {
.withTolerations(existing)
.build();
- new KueuePodSetFlavor(Map.of("instance-type", "spot", "pool", "p1"),
List.of(added))
+ new KueuePodSetInfo(Map.of("instance-type", "spot", "pool", "p1"),
List.of(added))
.applyTo(podSpec);
assertEquals(
@@ -69,7 +71,7 @@ class KueuePodSetFlavorTest {
Toleration existing = toleration("spot", "Equal", "true", "NoExecute",
30L);
PodSpec podSpec = new PodSpecBuilder().withTolerations(existing).build();
- new KueuePodSetFlavor(
+ new KueuePodSetInfo(
Map.of(),
List.of(
toleration("spot", null, "true", "NoExecute", 60L),
@@ -82,22 +84,72 @@ class KueuePodSetFlavorTest {
podSpec.getTolerations());
}
+ @Test
+ void applyToAddsLabelsAndAnnotationsToEmptyPodTemplate() {
+ // The executor pod template of a SparkApplication may have neither
metadata nor a pod spec
+ PodTemplateSpec template = new PodTemplateSpec();
+ Toleration toleration = toleration("provisioned", "Exists", null,
"NoSchedule", null);
+
+ new KueuePodSetInfo(
+ Map.of("pool", "provisioned"),
+ List.of(toleration),
+ Map.of("team", "a"),
+ Map.of("autoscaling.x-k8s.io/consume-provisioning-request", "pr"))
+ .applyTo(template);
+
+ assertEquals(Map.of("team", "a"), template.getMetadata().getLabels());
+ assertEquals(
+ Map.of("autoscaling.x-k8s.io/consume-provisioning-request", "pr"),
+ template.getMetadata().getAnnotations());
+ assertEquals(Map.of("pool", "provisioned"),
template.getSpec().getNodeSelector());
+ assertEquals(List.of(toleration), template.getSpec().getTolerations());
+ }
+
+ @Test
+ void applyToKeepsExistingLabelsAndAnnotations() {
+ PodTemplateSpec template =
+ new PodTemplateSpecBuilder()
+ .withNewMetadata()
+ .withLabels(Map.of("spark-role", "worker", "team", "a"))
+ .withAnnotations(Map.of("note", "kept"))
+ .endMetadata()
+ .withNewSpec()
+ .withNodeSelector(Map.of("zone", "a"))
+ .endSpec()
+ .build();
+
+ new KueuePodSetInfo(
+ Map.of("pool", "p1"),
+ List.of(),
+ Map.of("team", "a", "tier", "batch"),
+ Map.of("autoscaling.x-k8s.io/provisioning-class-name", "class"))
+ .applyTo(template);
+
+ assertEquals(
+ Map.of("spark-role", "worker", "team", "a", "tier", "batch"),
+ template.getMetadata().getLabels());
+ assertEquals(
+ Map.of("note", "kept", "autoscaling.x-k8s.io/provisioning-class-name",
"class"),
+ template.getMetadata().getAnnotations());
+ assertEquals(Map.of("zone", "a", "pool", "p1"),
template.getSpec().getNodeSelector());
+ }
+
@Test
void isSameToleration() {
assertTrue(
- KueuePodSetFlavor.isSameToleration(
+ KueuePodSetInfo.isSameToleration(
toleration("k", "", "v", "NoSchedule", null),
toleration("k", "Equal", "v", "NoSchedule", 10L)));
assertTrue(
- KueuePodSetFlavor.isSameToleration(
+ KueuePodSetInfo.isSameToleration(
toleration("k", "Exists", null, null, null),
toleration("k", "Exists", null, null, null)));
assertFalse(
- KueuePodSetFlavor.isSameToleration(
+ KueuePodSetInfo.isSameToleration(
toleration("k", "Exists", null, null, null),
toleration("k", "Equal", null, null, null)));
assertFalse(
- KueuePodSetFlavor.isSameToleration(
+ KueuePodSetInfo.isSameToleration(
toleration("k", "Equal", "v", "NoSchedule", null),
toleration("k", "Equal", "v", "NoExecute", null)));
}
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtilsTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtilsTest.java
index 90efba4a..4cfe4675 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtilsTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtilsTest.java
@@ -77,8 +77,10 @@ import org.apache.spark.k8s.operator.context.SparkAppContext;
import
org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils.AdmissionResponse;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils.AdmissionResult;
import org.apache.spark.k8s.operator.kueue.v1beta2.Admission;
+import org.apache.spark.k8s.operator.kueue.v1beta2.AdmissionCheckState;
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSet;
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSetAssignment;
+import org.apache.spark.k8s.operator.kueue.v1beta2.PodSetUpdate;
import org.apache.spark.k8s.operator.kueue.v1beta2.PriorityClassRef;
import org.apache.spark.k8s.operator.kueue.v1beta2.RequeueState;
import org.apache.spark.k8s.operator.kueue.v1beta2.ResourceFlavor;
@@ -216,13 +218,13 @@ class KueueWorkloadUtilsTest {
if (requested) {
// They are applied again, so the flavors which Kueue assigned to them
are not dropped
verify(context)
- .setKueuePodSetFlavors(
+ .setKueuePodSetInfos(
Map.of(
"executor",
- new KueuePodSetFlavor(Map.of("pool", "spot"),
List.of(toleration("spot")))));
+ new KueuePodSetInfo(Map.of("pool", "spot"),
List.of(toleration("spot")))));
} else {
// They start without Kueue, so the flavors are not read, which could
hold them back
- verify(context, never()).setKueuePodSetFlavors(any());
+ verify(context, never()).setKueuePodSetInfos(any());
}
}
@@ -445,7 +447,7 @@ class KueueWorkloadUtilsTest {
&& requeueAfter.compareTo(Duration.ofSeconds(60)) <= 0,
requeueAfter.toString());
Assertions.assertNotNull(getWorkload());
- verify(context, never()).setKueuePodSetFlavors(any());
+ verify(context, never()).setKueuePodSetInfos(any());
// The wait is reported with the reason of the eviction
ArgumentCaptor<EventRecord> captor =
ArgumentCaptor.forClass(EventRecord.class);
verify(eventRecorder).record(captor.capture());
@@ -934,7 +936,7 @@ class KueueWorkloadUtilsTest {
}
@Test
- void resolvePodSetFlavorsMergesFlavorsOfEachPodSet() {
+ void resolvePodSetInfosMergesFlavorsOfEachPodSet() {
Toleration spot = toleration("spot");
Toleration gpu = toleration("gpu");
createFlavor("cpu-flavor", Map.of("pool", "cpu", "zone", "a"),
List.of(spot));
@@ -942,47 +944,47 @@ class KueueWorkloadUtilsTest {
// Like Kueue, a flavor assigned to several resources is applied once
Map<String, String> driverFlavors = Map.of("cpu", "cpu-flavor", "memory",
"cpu-flavor");
// Like Kueue, a later flavor overwrites a node label, which is in the
resource name order.
- // The reverse insertion order pins the assertion to the sorting of
`resolvePodSetFlavors`.
+ // The reverse insertion order pins the assertion to the sorting of
`resolvePodSetInfos`.
Map<String, String> executorFlavors = new LinkedHashMap<>();
executorFlavors.put("nvidia.com/gpu", "gpu-flavor");
executorFlavors.put("cpu", "cpu-flavor");
- Map<String, KueuePodSetFlavor> flavors =
- KueueWorkloadUtils.resolvePodSetFlavors(
+ Map<String, KueuePodSetInfo> flavors =
+ KueueWorkloadUtils.resolvePodSetInfos(
kubernetesClient,
admittedWorkload(Map.of("driver", driverFlavors, "executor",
executorFlavors)));
Assertions.assertEquals(
Map.of(
"driver",
- new KueuePodSetFlavor(Map.of("pool", "cpu", "zone", "a"),
List.of(spot)),
+ new KueuePodSetInfo(Map.of("pool", "cpu", "zone", "a"),
List.of(spot)),
"executor",
- new KueuePodSetFlavor(
+ new KueuePodSetInfo(
Map.of("pool", "gpu", "zone", "a", "accelerator", "a100"),
List.of(spot, gpu))),
flavors);
}
@Test
- void resolvePodSetFlavorsWithoutAdmissionIsEmpty() {
+ void resolvePodSetInfosWithoutAdmissionIsEmpty() {
Workload admitted = workload("owner-uid-1", 1);
admitted.setStatus(status("Admitted", "True"));
Assertions.assertEquals(
- Map.of(), KueueWorkloadUtils.resolvePodSetFlavors(kubernetesClient,
admitted));
+ Map.of(), KueueWorkloadUtils.resolvePodSetInfos(kubernetesClient,
admitted));
Assertions.assertEquals(
Map.of(),
- KueueWorkloadUtils.resolvePodSetFlavors(kubernetesClient,
admittedWorkload(Map.of())));
+ KueueWorkloadUtils.resolvePodSetInfos(kubernetesClient,
admittedWorkload(Map.of())));
}
@Test
- void resolvePodSetFlavorsSkipsFlavorsWithoutNodeLabelsAndTolerations() {
+ void resolvePodSetInfosSkipsFlavorsWithoutNodeLabelsAndTolerations() {
// Kueue's stock `default-flavor` has neither, so its pod sets are built
as they are without
// Kueue, instead of being rebuilt around an empty pod template.
createFlavor("default-flavor", Map.of(), List.of());
createFlavor("spot-flavor", Map.of("pool", "spot"), List.of());
- Map<String, KueuePodSetFlavor> flavors =
- KueueWorkloadUtils.resolvePodSetFlavors(
+ Map<String, KueuePodSetInfo> flavors =
+ KueueWorkloadUtils.resolvePodSetInfos(
kubernetesClient,
admittedWorkload(
Map.of(
@@ -990,47 +992,215 @@ class KueueWorkloadUtilsTest {
"executor", Map.of("cpu", "spot-flavor"))));
Assertions.assertEquals(
- Map.of("executor", new KueuePodSetFlavor(Map.of("pool", "spot"),
List.of())), flavors);
+ Map.of("executor", new KueuePodSetInfo(Map.of("pool", "spot"),
List.of())), flavors);
}
@Test
- void checkNoNodeSelectorConflictAllowsTheSameNodeSelector() {
- Map<String, KueuePodSetFlavor> flavors =
- Map.of("executor", new KueuePodSetFlavor(Map.of("pool", "cpu"),
List.of()));
+ void checkNoPodTemplateConflictAllowsTheSameNodeSelector() {
+ Map<String, KueuePodSetInfo> flavors =
+ Map.of("executor", new KueuePodSetInfo(Map.of("pool", "cpu"),
List.of()));
Assertions.assertDoesNotThrow(
() ->
- KueueWorkloadUtils.checkNoNodeSelectorConflict(
+ KueueWorkloadUtils.checkNoPodTemplateConflict(
flavors, workloadWithNodeSelector(Map.of("pool", "cpu",
"zone", "a"))));
}
@Test
- void checkNoNodeSelectorConflictFailsOnDifferentValue() {
- Map<String, KueuePodSetFlavor> flavors =
- Map.of("executor", new KueuePodSetFlavor(Map.of("pool", "cpu"),
List.of()));
+ void checkNoPodTemplateConflictFailsOnDifferentNodeSelector() {
+ Map<String, KueuePodSetInfo> flavors =
+ Map.of("executor", new KueuePodSetInfo(Map.of("pool", "cpu"),
List.of()));
IllegalArgumentException e =
Assertions.assertThrows(
IllegalArgumentException.class,
() ->
- KueueWorkloadUtils.checkNoNodeSelectorConflict(
+ KueueWorkloadUtils.checkNoPodTemplateConflict(
flavors, workloadWithNodeSelector(Map.of("pool", "gpu"))));
Assertions.assertTrue(e.getMessage().contains("executor"), e.getMessage());
Assertions.assertTrue(e.getMessage().contains("pool"), e.getMessage());
}
@Test
- void resolvePodSetFlavorsFailsOnMissingFlavor() {
+ void checkNoPodTemplateConflictFailsOnDifferentLabelOrAnnotation() {
+ // Like Kueue's podset.Merge, the labels and annotations of the admission
checks must not
+ // change the ones of the pods either. The pod set template has no pod
spec here.
+ Workload desired = workloadWithMetadata(Map.of("team", "a"),
Map.of("note", "x"));
+ KueuePodSetInfo same = podSetInfoWithMetadata(Map.of("team", "a"),
Map.of("note", "x"));
+ KueuePodSetInfo otherLabel = podSetInfoWithMetadata(Map.of("team", "b"),
Map.of());
+ KueuePodSetInfo otherAnnotation = podSetInfoWithMetadata(Map.of(),
Map.of("note", "y"));
+
+ Assertions.assertDoesNotThrow(
+ () -> KueueWorkloadUtils.checkNoPodTemplateConflict(Map.of("executor",
same), desired));
+ IllegalArgumentException labels =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ KueueWorkloadUtils.checkNoPodTemplateConflict(
+ Map.of("executor", otherLabel), desired));
+ Assertions.assertTrue(labels.getMessage().contains("labels"),
labels.getMessage());
+ Assertions.assertTrue(labels.getMessage().contains("team"),
labels.getMessage());
+ IllegalArgumentException annotations =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ KueueWorkloadUtils.checkNoPodTemplateConflict(
+ Map.of("executor", otherAnnotation), desired));
+ Assertions.assertTrue(
+ annotations.getMessage().contains("annotations"),
annotations.getMessage());
+ Assertions.assertTrue(annotations.getMessage().contains("note"),
annotations.getMessage());
+ }
+
+ @Test
+ void resolvePodSetInfosFailsOnMissingFlavor() {
KubernetesClientException e =
Assertions.assertThrows(
KubernetesClientException.class,
() ->
- KueueWorkloadUtils.resolvePodSetFlavors(
+ KueueWorkloadUtils.resolvePodSetInfos(
kubernetesClient,
admittedWorkload(Map.of("executor", Map.of("cpu",
"missing-flavor")))));
Assertions.assertEquals(404, e.getCode());
}
+ @Test
+ void resolvePodSetInfosMergesAdmissionCheckUpdates() {
+ Toleration spot = toleration("spot");
+ Toleration provisioned = toleration("provisioned");
+ createFlavor("cpu-flavor", Map.of("pool", "cpu"), List.of(spot));
+ // Like Kueue, the admission checks are merged in the order of the list,
which Kueue keeps
+ // sorted by name, and each of them contributes the first update of a pod
set only.
+ Workload admitted =
+ admittedWorkload(
+ Map.of(
+ "driver", Map.of("cpu", "cpu-flavor"),
+ "executor", Map.of("cpu", "cpu-flavor")),
+ admissionCheck(
+ "a-check",
+ PodSetUpdate.builder()
+ .name("executor")
+ .labels(Map.of("team", "a"))
+ .annotations(Map.of("provisioning", "pr-1"))
+ // The node label of the flavor with the same value is no
conflict
+ .nodeSelector(Map.of("pool", "cpu"))
+ // The toleration of the flavor is not added again
+ .tolerations(List.of(spot, provisioned))
+ .build(),
+ PodSetUpdate.builder().name("executor").labels(Map.of("team",
"b")).build()),
+ admissionCheck(
+ "b-check",
+
PodSetUpdate.builder().name("driver").annotations(Map.of("class", "c")).build(),
+ PodSetUpdate.builder()
+ .name("executor")
+ .labels(Map.of("tier", "batch"))
+ .tolerations(List.of(provisioned))
+ .build()));
+
+ Assertions.assertEquals(
+ Map.of(
+ "driver",
+ new KueuePodSetInfo(
+ Map.of("pool", "cpu"), List.of(spot), Map.of(),
Map.of("class", "c")),
+ "executor",
+ new KueuePodSetInfo(
+ Map.of("pool", "cpu"),
+ List.of(spot, provisioned),
+ Map.of("team", "a", "tier", "batch"),
+ Map.of("provisioning", "pr-1"))),
+ KueueWorkloadUtils.resolvePodSetInfos(kubernetesClient, admitted));
+ }
+
+ @Test
+ void resolvePodSetInfosKeepsAPodSetWithAdmissionCheckUpdatesOnly() {
+ // e.g. a ProvisioningRequest admission check with Kueue's stock
`default-flavor`
+ createFlavor("default-flavor", Map.of(), List.of());
+ Map<String, String> annotations =
+ Map.of(
+ "autoscaling.x-k8s.io/consume-provisioning-request", "pr",
+ "autoscaling.x-k8s.io/provisioning-class-name", "class");
+ Workload admitted =
+ admittedWorkload(
+ Map.of(
+ "driver", Map.of("cpu", "default-flavor"),
+ "executor", Map.of("cpu", "default-flavor")),
+ admissionCheck(
+ "provisioning",
+
PodSetUpdate.builder().name("executor").annotations(annotations).build()));
+
+ Assertions.assertEquals(
+ Map.of("executor", podSetInfoWithMetadata(Map.of(), annotations)),
+ KueueWorkloadUtils.resolvePodSetInfos(kubernetesClient, admitted));
+ }
+
+ @Test
+ void resolvePodSetInfosFailsOnConflictingAdmissionCheckUpdates() {
+ createFlavor("cpu-flavor", Map.of("pool", "cpu"), List.of());
+ Map<String, Map<String, String>> podSetFlavors =
+ Map.of("executor", Map.of("cpu", "cpu-flavor"));
+ // Like Kueue's PodSetInfo.Merge, an update must not change the node
selector of a flavor
+ Workload overFlavor =
+ admittedWorkload(
+ podSetFlavors,
+ admissionCheck(
+ "provisioning",
+ PodSetUpdate.builder()
+ .name("executor")
+ .nodeSelector(Map.of("pool", "provisioned"))
+ .build()));
+ // Nor the labels which an earlier admission check set
+ Workload overCheck =
+ admittedWorkload(
+ podSetFlavors,
+ admissionCheck(
+ "a-check",
+ PodSetUpdate.builder().name("executor").labels(Map.of("team",
"a")).build()),
+ admissionCheck(
+ "b-check",
+ PodSetUpdate.builder().name("executor").labels(Map.of("team",
"b")).build()));
+
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> KueueWorkloadUtils.resolvePodSetInfos(kubernetesClient,
overFlavor));
+ Assertions.assertTrue(e.getMessage().contains("provisioning"),
e.getMessage());
+ Assertions.assertTrue(e.getMessage().contains("executor"), e.getMessage());
+ Assertions.assertTrue(e.getMessage().contains("pool"), e.getMessage());
+ e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> KueueWorkloadUtils.resolvePodSetInfos(kubernetesClient,
overCheck));
+ Assertions.assertTrue(e.getMessage().contains("b-check"), e.getMessage());
+ Assertions.assertTrue(e.getMessage().contains("team"), e.getMessage());
+ }
+
+ @Test
+ void admissionCheckUpdateConflictingWithThePodsReleasesTheWorkload() {
+ // Like Kueue's ErrInvalidPodSetUpdate, the conflict is permanent, so the
quota is released
+ // before the caller fails the resource
+ createFlavor("default-flavor", Map.of(), List.of());
+ KueueWorkloadUtils.requestAdmission(
+ kubernetesClient, workloadWithMetadata(Map.of("team", "a"), Map.of()));
+ Workload admitted =
+ admittedWorkload(
+ Map.of("executor", Map.of("cpu", "default-flavor")),
+ admissionCheck(
+ "check",
+ PodSetUpdate.builder().name("executor").labels(Map.of("team",
"b")).build()));
+ admit(admitted.getStatus());
+ SparkAppContext context = context(kubernetesClient);
+
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ KueueWorkloadUtils.holdForAdmission(
+ context, workloadWithMetadata(Map.of("team", "a"),
Map.of()), "driver"));
+
+ Assertions.assertTrue(e.getMessage().contains("team"), e.getMessage());
+ Assertions.assertNull(getWorkload());
+ verify(context, never()).setKueuePodSetInfos(any());
+ }
+
@Test
@SuppressWarnings("unchecked")
void rejectedAdmissionWithFailedReleaseIsRetried() {
@@ -1070,25 +1240,39 @@ class KueueWorkloadUtilsTest {
Assertions.assertEquals(
Optional.of(ReconcileProgress.completeAndDefaultRequeue()),
KueueWorkloadUtils.holdForAdmission(context, desired, "driver"));
- verify(context, never()).setKueuePodSetFlavors(any());
+ verify(context, never()).setKueuePodSetInfos(any());
}
@Test
void admittedFlavorsAreAppliedWithoutRequestingTheAdmission() {
// A resource whose driver or master exists applies its resources again,
so the flavors of the
- // Workload which was admitted before are resolved again rather than
dropped.
+ // Workload which was admitted before are resolved again rather than
dropped, along with the
+ // updates of its admission checks.
createFlavor("spot-flavor", Map.of("pool", "spot"),
List.of(toleration("spot")));
KueueWorkloadUtils.requestAdmission(kubernetesClient,
workload("owner-uid-1", 1));
- admit(admittedWorkload(Map.of("executor", Map.of("cpu",
"spot-flavor"))).getStatus());
+ Workload admitted =
+ admittedWorkload(
+ Map.of("executor", Map.of("cpu", "spot-flavor")),
+ admissionCheck(
+ "provisioning",
+ PodSetUpdate.builder()
+ .name("executor")
+ .annotations(Map.of("provisioning", "pr-1"))
+ .build()));
+ admit(admitted.getStatus());
SparkAppContext context = context(kubernetesClient);
- Assertions.assertEquals(Optional.empty(),
KueueWorkloadUtils.applyAdmittedFlavors(context));
+ Assertions.assertEquals(Optional.empty(),
KueueWorkloadUtils.applyAdmittedPodSetInfos(context));
verify(context)
- .setKueuePodSetFlavors(
+ .setKueuePodSetInfos(
Map.of(
"executor",
- new KueuePodSetFlavor(Map.of("pool", "spot"),
List.of(toleration("spot")))));
+ new KueuePodSetInfo(
+ Map.of("pool", "spot"),
+ List.of(toleration("spot")),
+ Map.of(),
+ Map.of("provisioning", "pr-1"))));
}
@Test
@@ -1103,11 +1287,12 @@ class KueueWorkloadUtilsTest {
IllegalArgumentException e =
Assertions.assertThrows(
- IllegalArgumentException.class, () ->
KueueWorkloadUtils.applyAdmittedFlavors(context));
+ IllegalArgumentException.class,
+ () -> KueueWorkloadUtils.applyAdmittedPodSetInfos(context));
Assertions.assertTrue(e.getMessage().contains("executor"), e.getMessage());
Assertions.assertTrue(e.getMessage().contains("pool"), e.getMessage());
- verify(context, never()).setKueuePodSetFlavors(any());
+ verify(context, never()).setKueuePodSetInfos(any());
// Unlike the admission, the quota is kept, since the pods it was reserved
for are running
Assertions.assertNotNull(getWorkload());
}
@@ -1117,11 +1302,11 @@ class KueueWorkloadUtilsTest {
SparkAppContext context = context(kubernetesClient);
// The Workload is gone, e.g. evicted and deleted, which must not hold the
running resources
- Assertions.assertEquals(Optional.empty(),
KueueWorkloadUtils.applyAdmittedFlavors(context));
+ Assertions.assertEquals(Optional.empty(),
KueueWorkloadUtils.applyAdmittedPodSetInfos(context));
KueueWorkloadUtils.requestAdmission(kubernetesClient,
workload("owner-uid-1", 1));
- Assertions.assertEquals(Optional.empty(),
KueueWorkloadUtils.applyAdmittedFlavors(context));
- verify(context, never()).setKueuePodSetFlavors(any());
+ Assertions.assertEquals(Optional.empty(),
KueueWorkloadUtils.applyAdmittedPodSetInfos(context));
+ verify(context, never()).setKueuePodSetInfos(any());
}
@Test
@@ -1133,8 +1318,8 @@ class KueueWorkloadUtilsTest {
// Applying no flavors would drop them from the resources which are
applied again
Assertions.assertEquals(
Optional.of(ReconcileProgress.completeAndDefaultRequeue()),
- KueueWorkloadUtils.applyAdmittedFlavors(context));
- verify(context, never()).setKueuePodSetFlavors(any());
+ KueueWorkloadUtils.applyAdmittedPodSetInfos(context));
+ verify(context, never()).setKueuePodSetInfos(any());
}
@Test
@@ -1155,7 +1340,7 @@ class KueueWorkloadUtilsTest {
Assertions.assertEquals(
Optional.of(ReconcileProgress.completeAndDefaultRequeue()),
- KueueWorkloadUtils.applyAdmittedFlavors(context));
+ KueueWorkloadUtils.applyAdmittedPodSetInfos(context));
ArgumentCaptor<EventRecord> captor =
ArgumentCaptor.forClass(EventRecord.class);
verify(eventRecorder).record(captor.capture());
@@ -1193,7 +1378,9 @@ class KueueWorkloadUtilsTest {
return flavor;
}
- private static Workload admittedWorkload(final Map<String, Map<String,
String>> podSetFlavors) {
+ private static Workload admittedWorkload(
+ final Map<String, Map<String, String>> podSetFlavors,
+ final AdmissionCheckState... admissionChecks) {
Workload workload = workload("owner-uid-1", 1);
WorkloadStatus status = status("Admitted", "True");
status.setAdmission(
@@ -1209,6 +1396,7 @@ class KueueWorkloadUtilsTest {
.build())
.toList())
.build());
+ status.setAdmissionChecks(List.of(admissionChecks));
workload.setStatus(status);
return workload;
}
@@ -1217,6 +1405,16 @@ class KueueWorkloadUtilsTest {
return new Toleration("NoSchedule", key, "Exists", null, null);
}
+ private static AdmissionCheckState admissionCheck(
+ final String name, final PodSetUpdate... podSetUpdates) {
+ return
AdmissionCheckState.builder().name(name).podSetUpdates(List.of(podSetUpdates)).build();
+ }
+
+ private static KueuePodSetInfo podSetInfoWithMetadata(
+ final Map<String, String> labels, final Map<String, String> annotations)
{
+ return new KueuePodSetInfo(Map.of(), List.of(), labels, annotations);
+ }
+
@Test
void workloadPriorityClassLabelTakesPrecedence() {
createWorkloadPriorityClass("high", 1000);
@@ -1684,6 +1882,23 @@ class KueueWorkloadUtilsTest {
return workload;
}
+ private static Workload workloadWithMetadata(
+ final Map<String, String> labels, final Map<String, String> annotations)
{
+ Workload workload = workload("owner-uid-1", 1);
+ workload
+ .getSpec()
+ .getPodSets()
+ .get(0)
+ .setTemplate(
+ new PodTemplateSpecBuilder()
+ .withNewMetadata()
+ .withLabels(labels)
+ .withAnnotations(annotations)
+ .endMetadata()
+ .build());
+ return workload;
+ }
+
private static Workload workloadWithDriver() {
Workload workload = workload("owner-uid-1", 2);
workload
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadTest.java
index 1fbaab7f..46e18ecf 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadTest.java
@@ -22,6 +22,7 @@ package org.apache.spark.k8s.operator.kueue.v1beta2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
@@ -34,6 +35,7 @@ import io.fabric8.kubernetes.api.model.ConditionBuilder;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder;
import io.fabric8.kubernetes.api.model.Quantity;
+import io.fabric8.kubernetes.api.model.Toleration;
import org.junit.jupiter.api.Test;
import org.apache.spark.k8s.operator.Constants;
@@ -188,6 +190,49 @@ class WorkloadTest {
assertFalse(json.has("podsReady"));
}
+ @Test
+ void testWorkloadStatusAdmissionChecksDeserialization() throws Exception {
+ // As Kueue and the controllers of the admission checks write them, with
unmodeled fields
+ String json =
+ "{\"admissionChecks\":[{\"name\":\"provisioning\",\"state\":\"Ready\","
+ +
"\"lastTransitionTime\":\"2026-10-07T00:00:00Z\",\"message\":\"Provisioned\","
+ +
"\"podSetUpdates\":[{\"name\":\"executor\",\"labels\":{\"team\":\"a\"},"
+ +
"\"annotations\":{\"autoscaling.x-k8s.io/consume-provisioning-request\":\"pr\"},"
+ + "\"nodeSelector\":{\"pool\":\"provisioned\"},"
+ +
"\"tolerations\":[{\"key\":\"provisioned\",\"operator\":\"Exists\","
+ + "\"effect\":\"NoSchedule\"}]}]},"
+ +
"{\"name\":\"another\",\"state\":\"Ready\",\"message\":\"\",\"retryCount\":1}]}";
+
+ WorkloadStatus status = OBJECT_MAPPER.readValue(json,
WorkloadStatus.class);
+
+ assertEquals(2, status.getAdmissionChecks().size());
+ AdmissionCheckState check = status.getAdmissionChecks().get(0);
+ assertEquals("provisioning", check.getName());
+ assertEquals(
+ List.of(
+ PodSetUpdate.builder()
+ .name("executor")
+ .labels(Map.of("team", "a"))
+
.annotations(Map.of("autoscaling.x-k8s.io/consume-provisioning-request", "pr"))
+ .nodeSelector(Map.of("pool", "provisioned"))
+ .tolerations(
+ List.of(new Toleration("NoSchedule", "provisioned",
"Exists", null, null)))
+ .build()),
+ check.getPodSetUpdates());
+ assertEquals("another", status.getAdmissionChecks().get(1).getName());
+ assertEquals(List.of(),
status.getAdmissionChecks().get(1).getPodSetUpdates());
+ }
+
+ @Test
+ void testWorkloadStatusWithoutAdmissionChecksOmitsThem() throws Exception {
+ WorkloadStatus status = OBJECT_MAPPER.readValue("{\"conditions\":[]}",
WorkloadStatus.class);
+
+ assertNull(status.getAdmissionChecks());
+ // The operator records a condition on a Workload without status as well,
which must not write
+ // the admission checks which Kueue owns
+ assertFalse(OBJECT_MAPPER.valueToTree(new
WorkloadStatus()).has("admissionChecks"));
+ }
+
@Test
void testInitSpecAndStatus() {
Workload workload = new Workload();
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactoryTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactoryTest.java
index d5dbebeb..c46abdb9 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactoryTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppResourceSpecFactoryTest.java
@@ -49,7 +49,7 @@ import org.mockito.ArgumentCaptor;
import org.apache.spark.k8s.operator.SparkAppResourceSpec;
import org.apache.spark.k8s.operator.SparkAppSubmissionWorker;
import org.apache.spark.k8s.operator.SparkApplication;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.spec.BaseApplicationTemplateSpec;
import org.apache.spark.k8s.operator.utils.ModelUtils;
@@ -95,6 +95,9 @@ class SparkAppResourceSpecFactoryTest {
BaseApplicationTemplateSpec.builder()
.podTemplateSpec(
new PodTemplateSpecBuilder()
+ .withNewMetadata()
+ .withLabels(Map.of("team", "a"))
+ .endMetadata()
.withNewSpec()
.withNodeSelector(Map.of("zone", "a"))
.withTolerations(userToleration)
@@ -132,17 +135,35 @@ class SparkAppResourceSpecFactoryTest {
mockClient,
mockWorker,
Map.of(
- "driver", new KueuePodSetFlavor(Map.of("pool", "cpu"),
List.of(spot)),
- "executor", new KueuePodSetFlavor(Map.of("pool", "gpu"),
List.of(gpu))));
+ "driver",
+ new KueuePodSetInfo(
+ Map.of("pool", "cpu"),
+ List.of(spot),
+ Map.of("tier", "batch"),
+ Map.of("provisioning", "pr-driver")),
+ "executor",
+ new KueuePodSetInfo(
+ Map.of("pool", "gpu"),
+ List.of(gpu),
+ Map.of("tier", "batch"),
+ Map.of("provisioning", "pr-executor"))));
// A driver without the pod template gets a new one
Pod driverTemplate = templates.get(DRIVER_SPARK_TEMPLATE_FILE_PROP_KEY);
assertEquals(Map.of("pool", "cpu"),
driverTemplate.getSpec().getNodeSelector());
assertEquals(List.of(spot), driverTemplate.getSpec().getTolerations());
+ assertEquals(Map.of("tier", "batch"),
driverTemplate.getMetadata().getLabels());
+ assertEquals(
+ Map.of("provisioning", "pr-driver"),
driverTemplate.getMetadata().getAnnotations());
// The flavor is merged into the executor pod template of the spec
Pod executorTemplate =
templates.get(EXECUTOR_SPARK_TEMPLATE_FILE_PROP_KEY);
assertEquals(Map.of("zone", "a", "pool", "gpu"),
executorTemplate.getSpec().getNodeSelector());
assertEquals(List.of(userToleration, gpu),
executorTemplate.getSpec().getTolerations());
+ // Spark keeps the labels and annotations of the pod templates on the
driver and executor pods
+ assertEquals(
+ Map.of("team", "a", "tier", "batch"),
executorTemplate.getMetadata().getLabels());
+ assertEquals(
+ Map.of("provisioning", "pr-executor"),
executorTemplate.getMetadata().getAnnotations());
// The temp files are cleaned up, and the SparkApplication is not modified
templateFiles.values().forEach(path ->
assertFalse(Files.exists(Path.of(path)), path));
assertEquals(originalSpec,
ModelUtils.objectMapper.writeValueAsString(app.getSpec()));
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
index ca185ff3..03b05e44 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
@@ -81,7 +81,7 @@ import org.apache.spark.k8s.operator.SparkAppSubmissionWorker;
import org.apache.spark.k8s.operator.SparkApplication;
import org.apache.spark.k8s.operator.config.SparkOperatorConf;
import org.apache.spark.k8s.operator.context.SparkAppContext;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadFactory;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
import org.apache.spark.k8s.operator.kueue.v1beta2.Admission;
@@ -1377,7 +1377,7 @@ class AppInitStepTest {
Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(),
progress);
Assertions.assertNull(getWorkload());
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
Assertions.assertEquals(
ApplicationStateSummary.DriverRequested,
application.getStatus().getCurrentState().getCurrentStateSummary());
@@ -1511,7 +1511,7 @@ class AppInitStepTest {
ApplicationStateSummary.DriverRequested,
application.getStatus().getCurrentState().getCurrentStateSummary());
// Like a driver found in the cache, it gets the flavors of the admitted
Workload again
- verify(mockContext).setKueuePodSetFlavors(any());
+ verify(mockContext).setKueuePodSetInfos(any());
// The driver found by the suspend check is not looked up again for the
Kueue admission
verify(mockContext).getCurrentAttemptDriverPod();
}
@@ -1829,9 +1829,9 @@ class AppInitStepTest {
"executor", Map.of("cpu", "spot-flavor", "memory",
"spot-flavor")));
appInitStep.reconcile(mockContext, recorder);
- KueuePodSetFlavor flavor = new KueuePodSetFlavor(Map.of("pool", "spot"),
List.of(spot));
+ KueuePodSetInfo flavor = new KueuePodSetInfo(Map.of("pool", "spot"),
List.of(spot));
InOrder inOrder = inOrder(mockContext);
- inOrder.verify(mockContext).setKueuePodSetFlavors(Map.of("driver", flavor,
"executor", flavor));
+ inOrder.verify(mockContext).setKueuePodSetInfos(Map.of("driver", flavor,
"executor", flavor));
inOrder.verify(mockContext).getDriverPreResourcesSpec();
Assertions.assertNotNull(
kubernetesClient.pods().inNamespace("default").withName("driver-pod").get());
@@ -1858,7 +1858,7 @@ class AppInitStepTest {
// Like Kueue, the conflict is permanent, so the quota is released
Assertions.assertEquals(ReconcileProgress.completeAndImmediateRequeue(),
progress);
Assertions.assertNull(getWorkload());
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
ArgumentCaptor<ApplicationStatus> captor =
ArgumentCaptor.forClass(ApplicationStatus.class);
verify(recorder).persistStatus(any(), captor.capture(), any());
Assertions.assertEquals(
@@ -1888,7 +1888,7 @@ class AppInitStepTest {
// Like a persistent admission failure, it is retried with the default
interval
Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(),
progress);
Assertions.assertNotNull(getWorkload());
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
verifyNoInteractions(recorder);
EventRecord event = captureEvents(2).get(1);
Assertions.assertEquals(EventType.WARNING, event.type());
@@ -1933,8 +1933,8 @@ class AppInitStepTest {
InOrder inOrder = inOrder(mockContext);
inOrder
.verify(mockContext)
- .setKueuePodSetFlavors(
- Map.of("driver", new KueuePodSetFlavor(Map.of("pool", "spot"),
List.of(spot))));
+ .setKueuePodSetInfos(
+ Map.of("driver", new KueuePodSetInfo(Map.of("pool", "spot"),
List.of(spot))));
inOrder.verify(mockContext).getDriverPreResourcesSpec();
Assertions.assertNotNull(getWorkload());
}
@@ -1964,7 +1964,7 @@ class AppInitStepTest {
ReconcileProgress progress = appInitStep.reconcile(mockContext, recorder);
Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(),
progress);
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
Assertions.assertNotNull(
kubernetesClient.pods().inNamespace("default").withName("driver-pod").get());
// The admitted Workload is released with the driver, like the one of a
queued application
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
index 3fab2f4f..1f136cb6 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
@@ -77,7 +77,7 @@ import org.apache.spark.k8s.operator.SparkCluster;
import org.apache.spark.k8s.operator.SparkClusterSubmissionWorker;
import org.apache.spark.k8s.operator.config.SparkOperatorConf;
import org.apache.spark.k8s.operator.context.SparkClusterContext;
-import org.apache.spark.k8s.operator.kueue.KueuePodSetFlavor;
+import org.apache.spark.k8s.operator.kueue.KueuePodSetInfo;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadFactory;
import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
import org.apache.spark.k8s.operator.kueue.v1beta2.Admission;
@@ -607,7 +607,7 @@ class ClusterInitStepTest {
Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(),
progress);
verify(mockClient, never()).resource(any(Workload.class));
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
ArgumentCaptor<ClusterStatus> captor =
ArgumentCaptor.forClass(ClusterStatus.class);
verify(recorder).persistStatus(any(), captor.capture());
Assertions.assertEquals(
@@ -1047,8 +1047,8 @@ class ClusterInitStepTest {
InOrder inOrder = inOrder(mockContext);
inOrder
.verify(mockContext)
- .setKueuePodSetFlavors(
- Map.of("master", new KueuePodSetFlavor(Map.of("pool", "spot"),
List.of(spot))));
+ .setKueuePodSetInfos(
+ Map.of("master", new KueuePodSetInfo(Map.of("pool", "spot"),
List.of(spot))));
inOrder.verify(mockContext).getMasterStatefulSetSpec();
// The admission is not requested again for a master which exists, nor is
the Workload released
verify(mockClient, never()).resource(any(Workload.class));
@@ -1087,7 +1087,7 @@ class ClusterInitStepTest {
// Like Kueue, the conflict is permanent, so the quota is released
Assertions.assertEquals(ReconcileProgress.completeAndImmediateRequeue(),
progress);
Assertions.assertNull(getWorkload());
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
verify(mockContext, never()).getMasterServiceSpec();
ArgumentCaptor<ClusterStatus> captor =
ArgumentCaptor.forClass(ClusterStatus.class);
verify(recorder).persistStatus(any(), captor.capture(), any());
@@ -1118,7 +1118,7 @@ class ClusterInitStepTest {
// The cluster is not failed permanently, the flavors are read again
Assertions.assertEquals(ReconcileProgress.completeAndDefaultRequeue(),
progress);
Assertions.assertNotNull(getWorkload());
- verify(mockContext, never()).setKueuePodSetFlavors(any());
+ verify(mockContext, never()).setKueuePodSetInfos(any());
verify(mockContext, never()).getMasterServiceSpec();
verifyNoInteractions(recorder);
EventRecord event = captureEvents(2).get(1);
diff --git a/tests/e2e/kueue/chainsaw-test.yaml
b/tests/e2e/kueue/chainsaw-test.yaml
index 17981438..4435a009 100644
--- a/tests/e2e/kueue/chainsaw-test.yaml
+++ b/tests/e2e/kueue/chainsaw-test.yaml
@@ -1149,3 +1149,227 @@ spec:
kubectl delete localqueue "$LOCAL_QUEUE" -n default
--ignore-not-found=true --wait=false
kubectl delete clusterqueue "$CLUSTER_QUEUE" --ignore-not-found=true
--wait=false
kubectl delete resourceflavor "$FLAVOR" --ignore-not-found=true
--wait=false
+ - name: pod-set-updates-of-admission-checks-reach-the-pods
+ # Each step uses its own queue objects, so that a step never waits for
Kueue to finish
+ # deleting the objects of another step.
+ bindings:
+ - name: FLAVOR
+ value: spark-e2e-admission-check-flavor
+ - name: CLUSTER_QUEUE
+ value: spark-e2e-admission-check-cluster-queue
+ - name: LOCAL_QUEUE
+ value: spark-e2e-admission-check-queue
+ - name: ADMISSION_CHECK
+ value: spark-e2e-admission-check
+ try:
+ - apply:
+ file: kueue-queues.yaml
+ # An admission check whose controller is this test
+ - apply:
+ resource:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: AdmissionCheck
+ metadata:
+ name: ($ADMISSION_CHECK)
+ spec:
+ controllerName: spark.apache.org/kueue-e2e
+ # Kueue keeps a ClusterQueue inactive while an admission check of it is
not active, which the
+ # controller of the check reports. The quota fits both resources (1 CPU
for each pod).
+ - script:
+ timeout: 30s
+ env:
+ - name: ADMISSION_CHECK
+ value: ($ADMISSION_CHECK)
+ - name: CLUSTER_QUEUE
+ value: ($CLUSTER_QUEUE)
+ content: |
+ set -e
+ kubectl patch admissioncheck "$ADMISSION_CHECK" --subresource=status
--type merge -p "$(
+ jq -nc --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{status:
{conditions: [{
+ type: "Active", status: "True", reason: "Active",
lastTransitionTime: $now,
+ message: "The test acts as the controller of the admission
check"}]}}')"
+ kubectl patch clusterqueue "$CLUSTER_QUEUE" --type json -p "$(
+ jq -nc --arg check "$ADMISSION_CHECK" '[
+ {op: "replace", path:
"/spec/resourceGroups/0/flavors/0/resources/0/nominalQuota",
+ value: "4"},
+ {op: "replace", path:
"/spec/resourceGroups/0/flavors/0/resources/1/nominalQuota",
+ value: "8Gi"},
+ {op: "add", path: "/spec/admissionChecksStrategy",
+ value: {admissionChecks: [{name: $check}]}}]')"
+ - apply:
+ file: spark-example-admission-check.yaml
+ - apply:
+ file: spark-cluster-example-admission-check.yaml
+ # Kueue reserves the quota of both Workloads, while they wait for the
admission check
+ - assert:
+ timeout: 60s
+ resource:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ metadata:
+ name: sparkapplication-spark-job-kueue-admission-check-test
+ namespace: default
+ status:
+ (conditions[?type == 'QuotaReserved' && status == 'True'] |
length(@)): 1
+ admissionChecks:
+ - name: ($ADMISSION_CHECK)
+ state: Pending
+ - assert:
+ timeout: 60s
+ resource:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ metadata:
+ name: sparkcluster-spark-cluster-kueue-admission-check-test
+ namespace: default
+ status:
+ (conditions[?type == 'QuotaReserved' && status == 'True'] |
length(@)): 1
+ admissionChecks:
+ - name: ($ADMISSION_CHECK)
+ state: Pending
+ # Like a ProvisioningRequest admission check, the test sets the check
Ready with an update of
+ # each pod set, whose label and annotation name the pod set. The node
selector is the node
+ # label of the flavor, which is no conflict, and the toleration matches no
taint, so the
+ # scheduling is not changed.
+ - script:
+ timeout: 30s
+ env:
+ - name: ADMISSION_CHECK
+ value: ($ADMISSION_CHECK)
+ content: |
+ set -e
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+ ready() {
+ workload=$1
+ shift
+ kubectl patch workload "$workload" -n default --subresource=status
--type merge -p "$(
+ jq -nc --arg check "$ADMISSION_CHECK" --arg now "$now" '{status:
{admissionChecks: [{
+ name: $check, state: "Ready", lastTransitionTime: $now,
+ message: "Checked by the test",
+ podSetUpdates: [$ARGS.positional[] | {
+ name: .,
+ labels: {"spark.apache.org/kueue-e2e-admission-check": .},
+ annotations: {"spark.apache.org/kueue-e2e-admission-check":
.},
+ nodeSelector: {"kubernetes.io/os": "linux"},
+ tolerations: [{key:
"spark.apache.org/kueue-e2e-admission-check",
+ operator: "Exists", effect: "NoSchedule"}]}]}]}}' --args
"$@")"
+ }
+ ready sparkapplication-spark-job-kueue-admission-check-test driver
executor
+ ready sparkcluster-spark-cluster-kueue-admission-check-test master
worker
+ # The updates reach the driver and the executor pods along with the
flavor, which the executor
+ # pods get through the executor pod template.
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: v1
+ kind: Pod
+ metadata:
+ namespace: default
+ labels:
+ spark.operator/spark-app-name:
spark-job-kueue-admission-check-test
+ spark-role: driver
+ spark.apache.org/kueue-e2e-admission-check: driver
+ annotations:
+ spark.apache.org/kueue-e2e-admission-check: driver
+ spec:
+ (tolerations[?key == 'spark.apache.org/kueue-e2e-admission-check']
| length(@)): 1
+ (tolerations[?key == 'spark.apache.org/kueue-e2e'] | length(@)): 1
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: v1
+ kind: Pod
+ metadata:
+ namespace: default
+ labels:
+ spark.operator/spark-app-name:
spark-job-kueue-admission-check-test
+ spark-role: executor
+ spark.apache.org/kueue-e2e-admission-check: executor
+ annotations:
+ spark.apache.org/kueue-e2e-admission-check: executor
+ spec:
+ (tolerations[?key == 'spark.apache.org/kueue-e2e-admission-check']
| length(@)): 1
+ (tolerations[?key == 'spark.apache.org/kueue-e2e'] | length(@)): 1
+ # So do the pod templates of the master and worker StatefulSets
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: spark-cluster-kueue-admission-check-test-master
+ namespace: default
+ spec:
+ template:
+ metadata:
+ labels:
+ spark.apache.org/kueue-e2e-admission-check: master
+ annotations:
+ spark.apache.org/kueue-e2e-admission-check: master
+ spec:
+ (tolerations[?key ==
'spark.apache.org/kueue-e2e-admission-check'] | length(@)): 1
+ (tolerations[?key == 'spark.apache.org/kueue-e2e'] |
length(@)): 1
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: spark-cluster-kueue-admission-check-test-worker
+ namespace: default
+ spec:
+ template:
+ metadata:
+ labels:
+ spark.apache.org/kueue-e2e-admission-check: worker
+ annotations:
+ spark.apache.org/kueue-e2e-admission-check: worker
+ spec:
+ (tolerations[?key ==
'spark.apache.org/kueue-e2e-admission-check'] | length(@)): 1
+ (tolerations[?key == 'spark.apache.org/kueue-e2e'] |
length(@)): 1
+ catch:
+ - describe:
+ apiVersion: spark.apache.org/v1
+ kind: SparkApplication
+ namespace: default
+ - describe:
+ apiVersion: spark.apache.org/v1
+ kind: SparkCluster
+ namespace: default
+ - describe:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ namespace: default
+ - describe:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: AdmissionCheck
+ - describe:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: ClusterQueue
+ - describe:
+ apiVersion: v1
+ kind: Pod
+ namespace: default
+ selector:
spark.operator/spark-app-name=spark-job-kueue-admission-check-test
+ - events:
+ namespace: default
+ finally:
+ - script:
+ timeout: 120s
+ env:
+ - name: FLAVOR
+ value: ($FLAVOR)
+ - name: CLUSTER_QUEUE
+ value: ($CLUSTER_QUEUE)
+ - name: LOCAL_QUEUE
+ value: ($LOCAL_QUEUE)
+ - name: ADMISSION_CHECK
+ value: ($ADMISSION_CHECK)
+ content: |
+ # Deleted at once, so that their clean-ups are awaited together
+ kubectl delete sparkapplication/spark-job-kueue-admission-check-test
sparkcluster/spark-cluster-kueue-admission-check-test -n default
--ignore-not-found=true
+ # Kueue finalizers may delay the deletion, which later steps do not
depend on.
+ kubectl delete localqueue "$LOCAL_QUEUE" -n default
--ignore-not-found=true --wait=false
+ kubectl delete clusterqueue "$CLUSTER_QUEUE" --ignore-not-found=true
--wait=false
+ kubectl delete resourceflavor "$FLAVOR" --ignore-not-found=true
--wait=false
+ kubectl delete admissioncheck "$ADMISSION_CHECK"
--ignore-not-found=true --wait=false
diff --git a/tests/e2e/kueue/spark-cluster-example-admission-check.yaml
b/tests/e2e/kueue/spark-cluster-example-admission-check.yaml
new file mode 100644
index 00000000..e3591192
--- /dev/null
+++ b/tests/e2e/kueue/spark-cluster-example-admission-check.yaml
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+apiVersion: spark.apache.org/v1
+kind: SparkCluster
+metadata:
+ name: spark-cluster-kueue-admission-check-test
+ namespace: default
+ labels:
+ kueue.x-k8s.io/queue-name: ($LOCAL_QUEUE)
+spec:
+ runtimeVersions:
+ sparkVersion: "4.2.0"
+ clusterTolerations:
+ instanceConfig:
+ initWorkers: 1
+ minWorkers: 1
+ maxWorkers: 1
+ sparkConf:
+ spark.kubernetes.container.image: "apache/spark:{{SPARK_VERSION}}-scala"
+ spark.master.ui.title: "Spark Cluster Kueue Admission Check E2E Test"
+ spark.master.rest.host: "0.0.0.0"
+ spark.ui.reverseProxy: "true"
diff --git a/tests/e2e/kueue/spark-example-admission-check.yaml
b/tests/e2e/kueue/spark-example-admission-check.yaml
new file mode 100644
index 00000000..b9241ac2
--- /dev/null
+++ b/tests/e2e/kueue/spark-example-admission-check.yaml
@@ -0,0 +1,35 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+apiVersion: spark.apache.org/v1
+kind: SparkApplication
+metadata:
+ name: spark-job-kueue-admission-check-test
+ namespace: default
+ labels:
+ kueue.x-k8s.io/queue-name: ($LOCAL_QUEUE)
+spec:
+ mainClass: "org.apache.spark.examples.SparkPi"
+ jars: "local:///opt/spark/examples/jars/spark-examples.jar"
+ # Runs long enough for the test to check the executor pod before it is
deleted
+ driverArgs: ["10000"]
+ sparkConf:
+ spark.executor.instances: "1"
+ spark.kubernetes.container.image: "apache/spark:{{SPARK_VERSION}}-scala"
+ spark.kubernetes.authenticate.driver.serviceAccountName: "spark"
+ runtimeVersions:
+ sparkVersion: "4.2.0"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]