Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,29 @@ import io.fabric8.kubernetes.api.model.metrics.v1beta1.{
PodMetricsList,
PodMetricsListBuilder
}
import io.fabric8.kubernetes.api.model.{Pod, PodBuilder, PodList, PodListBuilder, Quantity}
import io.fabric8.kubernetes.api.model.{
ContainerBuilder,
Pod,
PodBuilder,
PodList,
PodListBuilder,
Quantity,
ResourceRequirementsBuilder
}
import io.fabric8.kubernetes.client.dsl.{
MetricAPIGroupDSL,
MixedOperation,
NamespaceableResource,
NonNamespaceOperation,
PodMetricOperation,
PodResource
PodResource,
Resource
}
import io.fabric8.kubernetes.client.{KubernetesClient => Fabric8Client}
import org.apache.texera.common.config.KubernetesConfig
import org.mockito.Mockito.{mock, when}
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.{mock, times, verify, when}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

Expand Down Expand Up @@ -160,4 +172,149 @@ class KubernetesClientSpec extends AnyFlatSpec with Matchers {
k8s.getPodMetrics(1) shouldBe Map("cpu" -> "250m", "memory" -> "128Mi")
k8s.getPodMetrics(999) shouldBe empty
}
// ── single-pod lookups, creation and deletion ──
// These reach the rest of the fluent chain: withName(...).get() for the lookups,
// resource(pod).inNamespace(...).create() for creation, and .delete() for removal. Everything
// is driven through the constructor seam, so no cluster is involved.

/** Extends the namespace stub with the by-name pod operations `withName(...)` returns. */
private def clientWithNamedPod(podName: String, found: Pod): (Fabric8Client, PodResource) = {
val client = stubbedClient(Seq.empty, Seq.empty)
val podsInNamespace = client.pods().inNamespace(namespace)
val podResource = mock(classOf[PodResource])
when(podsInNamespace.withName(podName)).thenReturn(podResource)
when(podResource.get()).thenReturn(found)
(client, podResource)
}

/** A pod carrying one container whose resource limits are set. */
private def podWithLimits(cuid: Int, limits: Map[String, String]): Pod =
new PodBuilder()
.withNewMetadata()
.withName(KubernetesClient.generatePodName(cuid))
.endMetadata()
.withNewSpec()
.addToContainers(
new ContainerBuilder()
.withName("main")
.withResources(
new ResourceRequirementsBuilder()
.withLimits(limits.map { case (k, v) => k -> new Quantity(v) }.asJava)
.build()
)
.build()
)
.endSpec()
.build()

"generatePodURI" should "address the pod through its headless service inside the namespace" in {
// The URI is how a computing unit is reached once it is up, so every segment matters: a pod
// name alone, or the wrong namespace, resolves to nothing.
val uri = KubernetesClient.generatePodURI(7)
uri should startWith(KubernetesClient.generatePodName(7) + ".")
uri should include(s".${KubernetesConfig.computeUnitServiceName}.$namespace.svc.cluster.local:")
uri should endWith(s":${KubernetesConfig.computeUnitPortNumber}")
}

"getPodByName" should "wrap a found pod and report a missing one as None" in {
// fabric8 returns null rather than throwing for an absent pod, so the Option() wrapper is the
// only thing standing between a caller and an NPE.
val name = KubernetesClient.generatePodName(1)
val (found, _) = clientWithNamedPod(name, pod(1, "Running"))
val (absent, _) = clientWithNamedPod(name, null)

new KubernetesClient(found).getPodByName(name).map(_.getMetadata.getName) shouldBe Some(name)
new KubernetesClient(absent).getPodByName(name) shouldBe None
}

"podExists" should "follow the by-name lookup in both directions" in {
val name = KubernetesClient.generatePodName(2)
new KubernetesClient(clientWithNamedPod(name, pod(2, "Running"))._1).podExists(2) shouldBe true
new KubernetesClient(clientWithNamedPod(name, null)._1).podExists(2) shouldBe false
}

"getPodLimits" should "read the first container's limits and fall back to an empty map" in {
val name = KubernetesClient.generatePodName(3)
val withLimits =
clientWithNamedPod(name, podWithLimits(3, Map("cpu" -> "2", "memory" -> "4Gi")))._1
val missing = clientWithNamedPod(name, null)._1

new KubernetesClient(withLimits).getPodLimits(3) shouldBe Map("cpu" -> "2", "memory" -> "4Gi")
new KubernetesClient(missing).getPodLimits(3) shouldBe empty
Comment on lines +236 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Test pods that have no resource limits.

This test covers only a missing pod. It does not cover a found pod whose first container has no ResourceRequirements or no limits map.

KubernetesClient.getPodLimits dereferences container.getResources.getLimits, so either valid configuration can cause a null-pointer failure instead of returning the documented empty map. Add these cases and make the lookup null-safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/KubernetesClientSpec.scala`
around lines 236 - 243, Extend the “getPodLimits” test to cover a found pod
whose first container has null ResourceRequirements and one whose resources have
no limits map, both expecting an empty map. Update KubernetesClient.getPodLimits
to null-safely handle getResources and getLimits while preserving the existing
limits-map result and missing-pod fallback.

}

"createPod" should "refuse to overwrite a pod that already exists" in {
// Creating over a live unit would silently detach the running one from its owner.
val name = KubernetesClient.generatePodName(4)
val k8s = new KubernetesClient(clientWithNamedPod(name, pod(4, "Running"))._1)

val thrown = intercept[Exception] {
k8s.createPod(4, "1", "2Gi", "0", Map.empty)
}
thrown.getMessage should include("already exists")
}

it should "build the pod from the requested limits and env, and create it in the namespace" in {
val name = KubernetesClient.generatePodName(5)
val (client, _) = clientWithNamedPod(name, null)
val namespaceable = mock(classOf[NamespaceableResource[Pod]])
val resource = mock(classOf[Resource[Pod]])
val captor = ArgumentCaptor.forClass(classOf[Pod])
when(client.resource(any(classOf[Pod]))).thenReturn(namespaceable)
when(namespaceable.inNamespace(namespace)).thenReturn(resource)
// create()'s return value is not asserted; the pod is inspected through the captor below.
when(resource.create()).thenReturn(null)

new KubernetesClient(client).createPod(5, "2", "4Gi", "1", Map("UID" -> 9, "MODE" -> "batch"))

verify(client).resource(captor.capture())
val built = captor.getValue
built.getSpec.getHostname shouldBe name
built.getSpec.getSubdomain shouldBe KubernetesConfig.computeUnitServiceName
val container = built.getSpec.getContainers.asScala.head
val limits = container.getResources.getLimits.asScala.map { case (k, v) => k -> v.toString }
limits("cpu") shouldBe "2"
limits("memory") shouldBe "4Gi"
// Env values arrive as Any and reach the container as strings.
container.getEnv.asScala.map(e => e.getName -> e.getValue).toMap shouldBe
Map("UID" -> "9", "MODE" -> "batch")
}
Comment on lines +257 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify the pod creation call.

The test stubs inNamespace(namespace) and create(), but it does not verify either call. If createPod stops after client.resource(pod), this test still passes.

Verify namespaceable.inNamespace(namespace) and resource.create() after the call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/KubernetesClientSpec.scala`
around lines 257 - 281, Add verifications to the test “build the pod from the
requested limits and env, and create it in the namespace” after invoking
createPod: verify namespaceable.inNamespace(namespace) and resource.create() in
addition to the existing client.resource capture, preserving the current
pod-content assertions.


it should "mount a shared-memory volume only when a size is asked for" in {
// /dev/shm defaults to 64Mi in Kubernetes, which is too small for the Python workers, so the
// volume is the fix — but it must not appear when no size was requested.
def build(shm: Option[String]): Pod = {
val name = KubernetesClient.generatePodName(6)
val (client, _) = clientWithNamedPod(name, null)
val namespaceable = mock(classOf[NamespaceableResource[Pod]])
val resource = mock(classOf[Resource[Pod]])
val captor = ArgumentCaptor.forClass(classOf[Pod])
when(client.resource(any(classOf[Pod]))).thenReturn(namespaceable)
when(namespaceable.inNamespace(namespace)).thenReturn(resource)
// create()'s return value is not asserted; the pod is inspected through the captor below.
when(resource.create()).thenReturn(null)
new KubernetesClient(client).createPod(6, "1", "2Gi", "0", Map.empty, shm)
verify(client).resource(captor.capture())
captor.getValue
}

val withShm = build(Some("1Gi"))
withShm.getSpec.getVolumes.asScala.map(_.getName) should contain("dshm")
withShm.getSpec.getVolumes.asScala
.find(_.getName == "dshm")
.flatMap(v => Option(v.getEmptyDir))
.map(_.getSizeLimit.toString) shouldBe Some("1Gi")

Option(build(None).getSpec.getVolumes).map(_.asScala.map(_.getName)).getOrElse(Nil) should
not contain "dshm"
Comment on lines +301 to +309

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the /dev/shm container mount.

The test checks the dshm volume only. It does not check that computing-unit-master mounts dshm at /dev/shm.

Assert the container VolumeMount name and mount path. A volume without this mount does not configure shared memory for the container.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@computing-unit-managing-service/src/test/scala/org/apache/texera/service/util/KubernetesClientSpec.scala`
around lines 301 - 309, Extend the test around the withShm result from
build(Some("1Gi")) to inspect the computing-unit-master container’s
VolumeMounts, asserting that the dshm volume is mounted at /dev/shm. Keep the
existing dshm volume and no-volume assertions unchanged.

}

"deletePod" should "delete the pod for the cuid inside the namespace" in {
val name = KubernetesClient.generatePodName(8)
val (client, podResource) = clientWithNamedPod(name, pod(8, "Running"))

new KubernetesClient(client).deletePod(8)

verify(podResource, times(1)).delete()
}
}