HwangDongJun commented on code in PR #58187:
URL: https://github.com/apache/spark/pull/58187#discussion_r4163569067
##########
resource-managers/kubernetes/core/src/test/scala/org/apache/spark/deploy/k8s/submit/ClientSuite.scala:
##########
@@ -227,6 +230,203 @@ class ClientSuite extends SparkFunSuite with
BeforeAndAfter {
assert(configMap.getData.get(SPARK_CONF_FILE_NAME).contains("conf2key=conf2value"))
}
+ test("SPARK-38079: driver's own config map is created before the driver pod,
" +
+ "to avoid a mount race") {
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher)
+ submissionClient.run()
+ // The first resourceList(...) call is always the pre-resources
application, which
+ // happens before the driver pod is created. The driver's own config map
must be
+ // included there so that it exists in Kubernetes before the pod that
mounts it.
+ val firstResourceListCall =
createdResourcesArgumentCaptor.getAllValues.get(0)
+ val configMaps = firstResourceListCall.filter(_.isInstanceOf[ConfigMap])
+ assert(configMaps.nonEmpty,
+ "the driver's own config map must be sent as a pre-resource, before the
driver pod " +
+ "is created, to avoid a \"configmap ... not found\" mount race
(SPARK-38079)")
+
+ // Safety check: the pre-resource owner-reference refresh (the second
resourceList()
+ // call) must still set an owner reference on the config map, same as
before this
+ // change, so that it is still garbage-collected along with the driver pod.
+ val secondResourceListCall =
createdResourcesArgumentCaptor.getAllValues.get(1)
+ val refreshedConfigMap = secondResourceListCall
+ .filter(_.isInstanceOf[ConfigMap]).map(_.asInstanceOf[ConfigMap]).head
+ val ownerReferences = refreshedConfigMap.getMetadata.getOwnerReferences
+ assert(ownerReferences.size() === 1)
+ assert(ownerReferences.get(0).getName === POD_NAME)
+ assert(ownerReferences.get(0).getUid === DRIVER_POD_UID)
+ }
+
+ // SPARK-38079: making the driver's own config map (and other
credential-bearing resources,
+ // e.g. Kerberos keytab/delegation-token secrets) a pre-resource above means
they briefly
+ // exist without an owner reference (see the comment on
cleanupOrphanedPreResources in
+ // KubernetesClientApplication.scala). run() registers a shutdown hook to
best-effort clean
+ // those up if this process is terminated abruptly in that window. Actually
triggering a JVM
+ // shutdown hook from a test is impractical, so these tests instead call the
(package-private)
+ // cleanup method directly, the same way the hook itself would.
+ private def newRecoveryClientMocks(): (KubernetesClient, RESOURCE_LIST,
PodResource) = {
+ val recoveryClient = mock[KubernetesClient]
+ val recoveryResourceList = mock[RESOURCE_LIST]
+ val recoveryPodOperations = mock[PODS]
+ val recoveryPodsWithNamespace = mock[PODS_WITH_NAMESPACE]
+ val recoveryNamedPod = mock[PodResource]
+ doReturn(recoveryResourceList)
+
.when(recoveryClient).resourceList(ArgumentMatchers.any[Array[HasMetadata]]():
_*)
+ when(recoveryClient.pods()).thenReturn(recoveryPodOperations)
+
when(recoveryPodOperations.inNamespace(kconf.namespace)).thenReturn(recoveryPodsWithNamespace)
+
when(recoveryPodsWithNamespace.withName(POD_NAME)).thenReturn(recoveryNamedPod)
+ (recoveryClient, recoveryResourceList, recoveryNamedPod)
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup deletes orphaned pre-resources and
the driver pod " +
+ "if this submission created it") {
+ val (recoveryClient, recoveryResourceList, recoveryNamedPod) =
newRecoveryClientMocks()
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => recoveryClient))
+
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = true, podCreatedByUs =
true)
+
+ verify(recoveryResourceList).delete()
+ verify(recoveryNamedPod).delete()
+ // The recovery client is built fresh for this cleanup and must not leak.
+ verify(recoveryClient).close()
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup does not delete the driver pod if
this submission " +
+ "never created it") {
+ val (recoveryClient, recoveryResourceList, recoveryNamedPod) =
newRecoveryClientMocks()
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => recoveryClient))
+
+ // podCreatedByUs = false: e.g. the pod creation API call itself never
succeeded (or a
+ // differently-submitted application happens to be using the same pod
name), so this
+ // cleanup must not delete a pod it did not create.
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = true, podCreatedByUs =
false)
+
+ verify(recoveryResourceList).delete()
+ verify(recoveryNamedPod, never()).delete()
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup is a no-op if pre-resources were
never applied") {
+ var recoveryClientFactoryInvoked = false
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => {
+ recoveryClientFactoryInvoked = true
+ mock[KubernetesClient]
+ }))
+
+ // preResourcesApplied = false: the pre-resource serverSideApply() call
itself never
+ // succeeded, so there is nothing to have been left orphaned -- and in
particular, the
+ // existing catch block for that call (in run()) has already handled
cleanup of whatever
+ // partial state that failed call may have left behind.
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = false, podCreatedByUs =
false)
+
+ assert(!recoveryClientFactoryInvoked,
+ "the recovery client must not be built at all when there is nothing to
clean up")
+ }
+
+ test("SPARK-38079: shutdown-hook cleanup swallows exceptions from the
recovery client " +
+ "(best-effort only)") {
+ val (recoveryClient, recoveryResourceList, recoveryNamedPod) =
newRecoveryClientMocks()
+ doThrow(new RuntimeException("simulated API server failure"))
+ .when(recoveryResourceList).delete()
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ recoveryClientFactoryOverride = Some(() => recoveryClient))
+
+ // Must not throw: this runs on the JVM shutdown-hook thread, where an
uncaught exception
+ // would only be printed to stderr and otherwise has no one left to
meaningfully handle it.
+ submissionClient.cleanupOrphanedPreResources(
+ PRE_RESOURCES, POD_NAME, preResourcesApplied = true, podCreatedByUs =
true)
+
+ // The pod delete is independent of the (failed) pre-resource delete
above, and must still
+ // be attempted.
+ verify(recoveryNamedPod).delete()
+ }
+
+ // SPARK-38079: the tests above all call cleanupOrphanedPreResources
directly, since actually
+ // triggering a JVM shutdown hook from a test is impractical. That leaves
the wiring in run()
+ // itself -- does it register a hook before applying pre-resources, and
remove that exact hook
+ // once done with them -- uncovered by those tests alone. These two tests
close that gap by
+ // injecting a fake ShutdownHookOps instead.
+ test("SPARK-38079: cleanup hook is registered before pre-resources are
applied and " +
+ "removed once run() completes successfully") {
+ var registeredHook: Option[() => Unit] = None
+ var removedRef: Option[Any] = None
+ val fakeOps = ShutdownHookOps(
+ addHook = { hook =>
+ registeredHook = Some(hook)
+ "fake-hook-ref"
+ },
+ removeHook = { ref =>
+ removedRef = Some(ref)
+ true
+ })
+ val submissionClient = new Client(
+ kconf,
+ driverBuilder,
+ kubernetesClient,
+ loggingPodStatusWatcher,
+ shutdownHookOpsOverride = Some(fakeOps))
+
+ submissionClient.run()
+
+ assert(registeredHook.isDefined,
Review Comment:
All four of these are addressed now:
- The `.edit()` call's lambda is actually invoked in the mock setup --
`namedPods.edit(...)` applies the captured function to a real pod and returns
the result, and the test asserts on that returned pod, not just that `edit()`
was called with *some* function.
- A dedicated test (pod creation failure) asserts `resourceList(...)` is
never called when `create()` fails.
- `InOrder` now pins down `create()` → `resourceList(...)` → `edit(...)`
happening in that order, not just independently.
- The vararg matchers are `ArgumentMatchers.any[Array[HasMetadata]]()`
(Scala) / `ArgumentMatchers.any[HasMetadata]()` for the single-resource
`delete()` path, which match the whole array/argument rather than one vararg
element.
Also -- while addressing this, I noticed this same suite still had a couple
of the diff-describing-comment and direct-comment-ID patterns you flagged in
[the comment-diet
reply](https://github.com/apache/spark/pull/58187#discussion_r4129910586) (a
prior cleanup commit covered the other three files but missed this one). Fixed
those in a follow-up commit.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]