# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
# pyre-strict
import dataclasses
import logging
import re
import select
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Any, TypedDict
try:
from kubernetes import client, config, watch
from kubernetes.client.rest import ApiException
from kubernetes.config.kube_config import KubeConfigMerger
except ImportError:
raise RuntimeError(
"please install kubernetes to use KubernetesJob. `pip install kubernetes`"
)
from monarch._rust_bindings.monarch_hyperactor.channel import ChannelTransport
from monarch._rust_bindings.monarch_hyperactor.config import configure
from monarch._src.actor.bootstrap import attach_to_workers
from monarch._src.job.job import JobState, JobTrait
from monarch.actor import attach
logger: logging.Logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler(sys.stderr))
logger.propagate = False
# Default monarch port for worker communication
_DEFAULT_MONARCH_PORT: int = 26600
_RFC_1123_MAX_LEN = 63
# Seconds to wait for `kubectl port-forward` to report it is ready before giving
# up, so a silently hung forward cannot stall job initialization indefinitely.
_PORT_FORWARD_START_TIMEOUT_SECONDS: int = 30
# MonarchMesh CRD coordinates
_MONARCHMESH_GROUP = "monarch.pytorch.org"
_MONARCHMESH_VERSION = "v1alpha1"
_MONARCHMESH_PLURAL = "monarchmeshes"
# Bootstrap script for provisioned worker pods.
# Each worker discovers its own FQDN and starts listening for connections.
_WORKER_BOOTSTRAP_SCRIPT: str = textwrap.dedent("""\
import os
import socket
from monarch.actor import run_worker_loop_forever
port = os.environ.get("MONARCH_PORT", "26600")
hostname = socket.getfqdn()
address = f"tcp://{hostname}:{port}@tcp://0.0.0.0:{port}"
run_worker_loop_forever(address=address, ca="trust_all_connections")
""")
@dataclasses.dataclass(frozen=True)
class ImageSpec:
"""Container image specification for provisioning worker pods.
Use this to provision MonarchMesh workers with a specific container
image and optional K8s resource requests/limits::
# Simple — image only
ImageSpec("ghcr.io/meta-pytorch/monarch:latest")
# With GPU resources
ImageSpec("ghcr.io/meta-pytorch/monarch:latest",
resources={"nvidia.com/gpu": 4})
Pass the resulting object to ``KubernetesJob.add_mesh(image_spec=...)``.
"""
image: str
"""Required container image to use for worker pods."""
resources: dict[str, str | int] | None = None
"""Optional K8s resource requests/limits (e.g. ``{"nvidia.com/gpu": 4}``)."""
@dataclasses.dataclass(frozen=True)
class KubeConfig:
"""Kubernetes configuration for connecting to the cluster.
Use this to specify a kubeconfig file for out-of-cluster usage of
KubernetesJob::
KubeConfig.from_path("/path/to/kubeconfig")
If both local and remote are none, in-cluster configuration is used.
"""
local: Path | None = None
remote: client.Configuration | None = None
@classmethod
def from_path(cls, path: str) -> "KubeConfig":
"""Create a KubeConfig from a local file path."""
return cls(local=Path(path).expanduser())
@classmethod
def from_config(cls, config: client.Configuration) -> "KubeConfig":
"""Create a KubeConfig from a remote host"""
return cls(remote=config)
@property
def out_of_cluster(self) -> bool:
"""Whether this kubeconfig is for out-of-cluster usage."""
return self.remote is not None or self.local is not None
def load(self) -> None:
if self.local is not None:
try:
config.load_kube_config(config_file=str(self.local))
proxy_url = _get_kubeconfig_proxy_url(self.local)
except config.ConfigException as e:
raise RuntimeError(
f"Failed to load kubeconfig file '{self.local}'"
) from e
if proxy_url is not None:
configuration = client.Configuration.get_default_copy()
configuration.proxy = proxy_url
client.Configuration.set_default(configuration)
elif self.remote is not None:
client.Configuration.set_default(self.remote)
else:
try:
config.load_incluster_config()
except config.ConfigException as e:
raise RuntimeError(
"Failed to load in-cluster Kubernetes config. "
"KubernetesJob must run inside a Kubernetes cluster."
) from e
def _get_kubeconfig_proxy_url(path: Path) -> str | None:
merged_config: Any = KubeConfigMerger(str(path)).config
if merged_config is None:
return None
# A malformed kubeconfig (e.g. missing current-context or a dangling
# cluster reference) raises KeyError/TypeError/AttributeError here. Surface
# it as a ConfigException so KubeConfig.load transforms it into a clear
# RuntimeError rather than leaking a raw traceback.
try:
context = merged_config["contexts"].get_with_name(
merged_config["current-context"]
)["context"]
cluster = merged_config["clusters"].get_with_name(context["cluster"])["cluster"]
proxy_url = cluster.safe_get("proxy-url")
except (KeyError, TypeError, AttributeError) as e:
raise config.ConfigException(f"malformed kubeconfig '{path}': {e}") from e
if proxy_url is not None and not isinstance(proxy_url, str):
raise config.ConfigException("kubeconfig proxy-url must be a string")
return proxy_url
@dataclasses.dataclass(frozen=True)
class _MonarchMeshPod:
name: str
ip: str
port: int
class _MeshConfigRequired(TypedDict):
service_name: str
label_selector: str
num_replicas: int
pod_rank_label: str
provisioned: bool
port: int
class _MeshConfig(_MeshConfigRequired, total=False):
labels: dict[str, str]
annotations: dict[str, str]
pod_template: client.V1PodTemplateSpec
[docs]class KubernetesJob(JobTrait):
"""
Job implementation for Kubernetes that discovers and connects to pods.
Supports two modes:
*Pre-provisioned* -- connect to pre-provisioned pods discovered via label
selectors. Compatible with the MonarchMesh operator, third-party
schedulers, or manually created pods. Used when ``image_spec`` or
``pod_template`` is not specified in ``add_mesh``.
*Provisioning* -- create MonarchMesh CRDs via the K8s API so the
pre-installed operator provisions StatefulSets and Services
automatically. Pass ``image_spec`` or ``pod_template`` (a
``V1PodTemplateSpec``) to ``add_mesh`` to enable provisioning for that
mesh. If the MonarchMesh CRD already exists, it is patched instead
of created.
"""
def __init__(
self,
namespace: str,
timeout: int | None = None,
kubeconfig: KubeConfig | None = None,
attach_to: str | None = None,
) -> None:
"""
Initialize a KubernetesJob.
Args:
namespace: Kubernetes namespace for all meshes
timeout: Maximum seconds to wait for pods to be ready for each mesh (default: None, wait indefinitely)
kubeconfig: Path to a kubeconfig file for out-of-cluster configuration (default: None, use in-cluster config)
attach_to: ZMQ-style address of a worker's monarch port for out-of-cluster
access (e.g. `"tcp://127.0.0.1:26600"`). The monarch port doubles
as the duplex attach address. Use
``kubectl port-forward pod/<pod-name> <local>:26600``
to forward the pod to localhost.
When ``kubeconfig`` is provided (out-of-cluster mode) and this is
not set, port-forwarding is set up automatically.
"""
configure(default_transport=ChannelTransport.TcpWithHostname)
self._namespace = namespace
self._timeout = timeout
self._kubeconfig: KubeConfig = kubeconfig or KubeConfig()
self._attach_to = attach_to
self._meshes: dict[str, _MeshConfig] = {}
self._port_forward_processes: list[subprocess.Popen[str]] = []
super().__init__()
# TODO: Consider adding monarch-rank label instead of relying on StatefulSet index by default if using MonarchMesh CRD.
[docs] def add_mesh(
self,
name: str,
num_replicas: int,
label_selector: str | None = None,
pod_rank_label: str = "apps.kubernetes.io/pod-index",
image_spec: ImageSpec | None = None,
port: int = _DEFAULT_MONARCH_PORT,
pod_template: client.V1PodTemplateSpec | None = None,
labels: dict[str, str] | None = None,
annotations: dict[str, str] | None = None,
) -> None:
"""
Add a mesh specification.
In *attach-only* mode (default), meshes are discovered by label
selector. In *provisioning* mode (``image_spec`` or ``pod_template``
supplied), a MonarchMesh CRD is created so the operator can
provision the pods.
Args:
name: Name of the mesh. Must follow RFC 1123 DNS label standard and Monarch hostname restriction:
* At most 63 characters
* only lowercase alphanumeric characters
* must start with an alphabetic character,
* and end with an alphanumeric character.
num_replicas: Number of pod replicas (expects all ranks 0 to num_replicas-1)
label_selector: Custom label selector for pod discovery. Cannot be set when provisioning.
pod_rank_label: Label key containing the pod rank. Cannot be customized when provisioning.
image_spec: ``ImageSpec`` with container image and optional resources for simple provisioning.
Mutually exclusive with ``pod_template``.
port: Monarch worker port (default: 26600).
pod_template: ``V1PodTemplateSpec`` for advanced provisioning (e.g. custom volumes, sidecars,
pod-level labels/annotations). Mutually exclusive with ``image_spec``.
labels: Optional labels to apply to the MonarchMesh CRD metadata.
Propagated by the operator to the StatefulSet metadata. To set
labels on the worker pods, use ``pod_template.metadata.labels``.
Only used when provisioning (``image_spec`` or ``pod_template`` supplied).
annotations: Optional annotations to apply to the MonarchMesh CRD metadata.
Propagated by the operator to the StatefulSet metadata. To set
annotations on the worker pods, use ``pod_template.metadata.annotations``.
Only used when provisioning (``image_spec`` or ``pod_template`` supplied).
Raises:
ValueError: On invalid name or conflicting parameters.
"""
if len(name) == 0:
raise ValueError("Empty mesh name is invalid.")
if len(name) > _RFC_1123_MAX_LEN:
raise ValueError(
f"Mesh name '{name}' is invalid. Name must contain at most 63 characters."
)
if not name.isalnum() or not name.islower():
raise ValueError(
f"Mesh name '{name}' is invalid. Name must contain only lowercase alphanumeric characters."
)
if not name[0].isalpha():
raise ValueError(
f"Mesh name '{name}' is invalid. Name must start with an alphabetic character."
)
if not name[-1].isalnum():
raise ValueError(
f"Mesh name '{name}' is invalid. Name must end with an alphanumeric character."
)
provisioned = image_spec is not None or pod_template is not None
if image_spec is not None and pod_template is not None:
raise ValueError("'image_spec' and 'pod_template' are mutually exclusive.")
if provisioned and label_selector is not None:
raise ValueError("'label_selector' cannot be customized when provisioning.")
if provisioned and pod_rank_label != "apps.kubernetes.io/pod-index":
raise ValueError("'pod_rank_label' cannot be customized when provisioning.")
if not provisioned and labels is not None:
raise ValueError("'labels' can only be set when provisioning.")
if not provisioned and annotations is not None:
raise ValueError("'annotations' can only be set when provisioning.")
mesh_entry: _MeshConfig = {
# The service name has a suffix appended to it controlled by the MonarchMesh config.
"service_name": f"{name}-svc",
"label_selector": label_selector
or f"app.kubernetes.io/name=monarch-worker,monarch.pytorch.org/mesh-name={name}",
"num_replicas": num_replicas,
"pod_rank_label": pod_rank_label,
"provisioned": provisioned,
"port": port,
}
if labels is not None:
mesh_entry["labels"] = labels
if annotations is not None:
mesh_entry["annotations"] = annotations
if image_spec is not None:
mesh_entry["pod_template"] = self._build_worker_pod_template(
image_spec, port
)
elif pod_template is not None:
mesh_entry["pod_template"] = pod_template
self._meshes[name] = mesh_entry
def _create(self, client_script: str | None) -> None:
"""
Create MonarchMesh CRDs for provisioned meshes.
Attach-only meshes are a no-op. For provisioned meshes, the
MonarchMesh custom resource is created (or patched if it already
exists) so the operator can provision the StatefulSet and
headless Service.
"""
if client_script is not None:
raise RuntimeError("KubernetesJob cannot run batch-mode scripts")
if not self._meshes:
raise ValueError("At least one mesh must be added using add_mesh()")
provisioned = {
name: cfg for name, cfg in self._meshes.items() if cfg.get("provisioned")
}
if not provisioned:
return
self._kubeconfig.load()
api_client = client.ApiClient()
api = client.CustomObjectsApi(api_client)
for mesh_name, mesh_config in provisioned.items():
pod_template_dict = api_client.sanitize_for_serialization(
mesh_config["pod_template"]
)
metadata: dict[str, Any] = {
"name": mesh_name,
"namespace": self._namespace,
}
if "labels" in mesh_config:
metadata["labels"] = mesh_config["labels"]
if "annotations" in mesh_config:
metadata["annotations"] = mesh_config["annotations"]
body: dict[str, Any] = {
"apiVersion": f"{_MONARCHMESH_GROUP}/{_MONARCHMESH_VERSION}",
"kind": "MonarchMesh",
"metadata": metadata,
"spec": {
"replicas": mesh_config["num_replicas"],
"port": mesh_config["port"],
"podTemplate": pod_template_dict,
},
}
try:
api.create_namespaced_custom_object(
group=_MONARCHMESH_GROUP,
version=_MONARCHMESH_VERSION,
namespace=self._namespace,
plural=_MONARCHMESH_PLURAL,
body=body,
)
logger.info("Created MonarchMesh '%s'", mesh_name)
except ApiException as e:
if e.status == 409:
# TODO: Consider throwing an error instead of patching if the CRD already exists.
api.patch_namespaced_custom_object(
group=_MONARCHMESH_GROUP,
version=_MONARCHMESH_VERSION,
namespace=self._namespace,
plural=_MONARCHMESH_PLURAL,
name=mesh_name,
body=body,
)
logger.info("MonarchMesh '%s' already exists, patched", mesh_name)
else:
raise
@staticmethod
def _build_worker_pod_template(
image_spec: ImageSpec,
port: int,
) -> client.V1PodTemplateSpec:
"""
Build a V1PodTemplateSpec for the MonarchMesh CRD.
Generates a single-container pod template with a worker bootstrap
script that starts ``run_worker_loop_forever``.
Args:
image_spec: ImageSpec with container image and optional resources.
port: Monarch worker port.
Returns:
V1PodTemplateSpec suitable for the ``podTemplate`` CRD field.
"""
resources = None
if image_spec.resources is not None:
k8s_resources = {str(k): str(v) for k, v in image_spec.resources.items()}
resources = client.V1ResourceRequirements(
requests=k8s_resources,
limits=k8s_resources,
)
env = [client.V1EnvVar(name="MONARCH_PORT", value=str(port))]
container = client.V1Container(
name="worker",
image=image_spec.image,
command=["python", "-u", "-c", _WORKER_BOOTSTRAP_SCRIPT],
env=env,
resources=resources,
)
return client.V1PodTemplateSpec(
spec=client.V1PodSpec(containers=[container]),
)
def _is_pod_worker_ready(self, pod: client.V1Pod) -> bool:
"""
Check if a pod is ready using pod status conditions.
Args:
pod: Kubernetes pod object
Returns:
True if pod has Ready condition with status True
"""
if not pod.status.conditions:
return False
for condition in pod.status.conditions:
if condition.type == "Ready":
return condition.status == "True"
return False
def _get_pod_rank(self, pod: client.V1Pod, pod_rank_label: str) -> int:
"""
Extract the pod rank from the specified label.
Args:
pod: Kubernetes pod object
pod_rank_label: Label key containing the pod rank
Returns:
Pod rank as an integer
Raises:
ValueError: If pod rank label is missing or invalid
"""
if not pod.metadata.labels:
raise ValueError(
f"Pod {pod.metadata.name} has no labels, cannot determine pod rank"
)
pod_rank_str = pod.metadata.labels.get(pod_rank_label)
if pod_rank_str is None:
raise ValueError(
f"Pod {pod.metadata.name} missing required label '{pod_rank_label}'"
)
try:
return int(pod_rank_str)
except ValueError as e:
raise ValueError(
f"Pod {pod.metadata.name} has invalid pod rank '{pod_rank_str}': {e}"
) from e
def _wait_for_ready_pods(
self,
label_selector: str,
num_replicas: int,
pod_rank_label: str,
timeout: int | None = None,
) -> list[_MonarchMeshPod]:
"""
Wait for all required pod ranks to be ready matching the label selector.
Ensures all ranks from 0 to num_replicas-1 are available before returning and ignores any pod outside this range.
Args:
label_selector: Kubernetes label selector
num_replicas: Number of pod replicas (expects ranks 0 to num_replicas-1)
pod_rank_label: Label key containing the pod rank for ordering
timeout: Maximum seconds to wait (None for no timeout)
Returns:
List of (pod_ip, monarch_port) tuples sorted by pod rank (0 to num_replicas-1)
Raises:
RuntimeError: If timeout reached, missing ranks, or watch error
"""
ready_pods_by_rank: dict[int, _MonarchMeshPod] = {}
# Load Kubernetes configuration
self._kubeconfig.load()
c = client.CoreV1Api()
w = watch.Watch()
try:
for event in w.stream(
c.list_namespaced_pod,
namespace=self._namespace,
label_selector=label_selector,
timeout_seconds=timeout,
):
event_type = event["type"]
pod = event["object"]
# Handle ERROR events immediately
if event_type == "ERROR":
raise RuntimeError(f"Watch error: {event.get('object', {})}")
# Extract pod rank (skip pods without valid rank label)
try:
pod_rank = self._get_pod_rank(pod, pod_rank_label)
except ValueError:
logger.warning(
f"Skipping pod {pod.metadata.name} due to missing or invalid pod rank label '{pod_rank_label}'"
)
continue
# Skip pods outside expected range
if pod_rank < 0 or pod_rank >= num_replicas:
logger.warning(
f"Pod {pod.metadata.name} has rank {pod_rank} outside expected range [0, {num_replicas - 1}]"
)
continue
# Handle DELETED events
if event_type == "DELETED":
ready_pods_by_rank.pop(pod_rank, None)
continue
# Only process ADDED/MODIFIED events from here
if event_type not in ("ADDED", "MODIFIED"):
continue
# Update ready pods based on current state
if self._is_pod_worker_ready(pod):
ready_pods_by_rank[pod_rank] = _MonarchMeshPod(
name=pod.metadata.name,
ip=pod.status.pod_ip,
port=self._discover_monarch_port(pod),
)
# Check if we have all required ranks (0 to num_replicas-1)
if len(ready_pods_by_rank) == num_replicas:
return [
ready_pods_by_rank[rank] for rank in range(num_replicas)
]
else:
# Pod is no longer ready, remove its rank
ready_pods_by_rank.pop(pod_rank, None)
# Watch ended without finding all required ranks
missing_ranks = set(range(num_replicas)) - set(ready_pods_by_rank.keys())
raise RuntimeError(
f"Watch ended with {len(ready_pods_by_rank)}/{num_replicas} ranks. "
f"Missing ranks: {sorted(missing_ranks)}"
)
except ApiException as e:
raise RuntimeError(f"Failed to watch pods: {e}") from e
finally:
w.stop()
# TODO: Consider using named port instead of env var for monarch port
def _discover_monarch_port(self, pod: client.V1Pod) -> int:
"""
Discover the monarch port from the pod specification.
Checks in order:
1. MONARCH_PORT environment variable in container spec
2. Falls back to default monarch_port
Args:
pod: Kubernetes pod object
Returns:
Port number for monarch communication
"""
for container in pod.spec.containers:
# Check for MONARCH_PORT env var
if container.env:
for env_var in container.env:
if env_var.name == "MONARCH_PORT" and env_var.value:
try:
return int(env_var.value)
except ValueError:
logger.warning(
f"Invalid MONARCH_PORT '{env_var.value}' in pod {pod.metadata.name}"
)
break
return _DEFAULT_MONARCH_PORT
def _port_forward_to_pod(self, pod: _MonarchMeshPod) -> str:
"""Start kubectl port-forward to the pod's monarch port.
The frontend address doubles as the duplex attach address, so
port-forwarding the monarch port is sufficient for both regular
messaging and out-of-cluster client attachment.
Returns the local ``tcp://127.0.0.1:<local_port>`` address.
"""
if shutil.which("kubectl") is None:
raise RuntimeError(
"kubectl is required for out-of-cluster port forwarding but was not found in PATH"
)
cmd = [
"kubectl",
"port-forward",
"--namespace",
self._namespace,
f"pod/{pod.name}",
f":{pod.port}",
]
if self._kubeconfig.local is not None:
cmd.extend(["--kubeconfig", str(self._kubeconfig.local)])
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if process.stdout is None:
raise RuntimeError(
f"failed to open stdout for kubectl port-forward to pod {pod.name}"
)
# kubectl prints "Forwarding from ..." to stdout once the tunnel is up.
# Guard the blocking read so a silently hung forward cannot stall job
# initialization indefinitely.
ready, _, _ = select.select(
[process.stdout], [], [], _PORT_FORWARD_START_TIMEOUT_SECONDS
)
if not ready:
process.kill()
process.wait()
raise RuntimeError(
f"kubectl port-forward to pod {pod.name} did not start within "
f"{_PORT_FORWARD_START_TIMEOUT_SECONDS}s"
)
first_line = process.stdout.readline()
if not first_line:
# kubectl closed stdout without announcing readiness. Terminate it
# and drain stderr with a deadline so a process that closed stdout
# while still running cannot block us.
process.terminate()
try:
_, stderr_output = process.communicate(
timeout=_PORT_FORWARD_START_TIMEOUT_SECONDS
)
except subprocess.TimeoutExpired:
process.kill()
_, stderr_output = process.communicate()
raise RuntimeError(
f"kubectl port-forward produced no output for pod {pod.name}: "
f"{stderr_output}"
)
match = re.search(
r"Forwarding from (?:127\.0\.0\.1|\[::1\]):(\d+) ->", first_line
)
if not match:
process.kill()
process.wait()
raise RuntimeError(
f"could not parse local port from kubectl output for pod {pod.name}: {first_line}"
)
local_port = int(match.group(1))
self._port_forward_processes.append(process)
logger.info(
"Port forwarding established to pod/%s on local port %d",
pod.name,
local_port,
)
return f"tcp://127.0.0.1:{local_port}"
def _terminate_port_forwards(self) -> None:
"""Terminate any running ``kubectl port-forward`` subprocesses."""
for process in self._port_forward_processes:
if process.poll() is None:
process.terminate()
process.wait()
self._port_forward_processes.clear()
def _state(self) -> JobState:
"""
Get the current state by connecting to ready pods for each mesh.
Returns:
JobState containing HostMesh objects for each configured mesh
"""
host_meshes = {}
attach_to = self._attach_to
# Discover all mesh pods first so we can set up port-forwarding
# before attaching to any workers.
all_mesh_pods: dict[str, list[_MonarchMeshPod]] = {}
for mesh_name, mesh_config in self._meshes.items():
# Wait for pods to be ready and discover their ports
pods = self._wait_for_ready_pods(
mesh_config["label_selector"],
mesh_config["num_replicas"],
mesh_config["pod_rank_label"],
timeout=self._timeout,
)
all_mesh_pods[mesh_name] = pods
# Set up out-of-cluster client attachment before connecting to workers.
# If anything below fails after a port-forward subprocess is started,
# terminate it so we do not leak kubectl processes and bound ports.
try:
if self._kubeconfig.out_of_cluster and attach_to is None:
# No explicit attach_to — auto-forward to the first pod's
# monarch port (which doubles as the duplex attach address).
for pods in all_mesh_pods.values():
if pods:
attach_to = self._port_forward_to_pod(pods[0])
break
if attach_to is None:
raise RuntimeError(
"out-of-cluster mode requires at least one ready pod "
"and no attach_to was provided"
)
if attach_to is not None:
logger.info(
"Attaching client gateway via duplex address: %s", attach_to
)
attach(attach_to)
for mesh_name, pods in all_mesh_pods.items():
# Create worker addresses using discovered IPs and ports
workers = [f"tcp://{pod.ip}:{pod.port}" for pod in pods]
# Create host mesh by attaching to workers
host_mesh = attach_to_workers(
name=mesh_name,
ca="trust_all_connections",
workers=workers, # type: ignore[arg-type]
)
host_meshes[mesh_name] = host_mesh
except Exception:
self._terminate_port_forwards()
raise
return JobState(host_meshes)
[docs] def can_run(self, spec: "JobTrait") -> bool:
"""
Check if this job can run the given spec.
Verifies that:
1. The spec is a KubernetesJob with matching configuration
2. The required pods are available and ready
Args:
spec: JobTrait specification to check
Returns:
True if this job matches the spec and all required pods are available
"""
if not (
isinstance(spec, KubernetesJob)
and spec._namespace == self._namespace
and spec._meshes == self._meshes
and self.active
):
return False
try:
for mesh_config in self._meshes.values():
self._wait_for_ready_pods(
mesh_config["label_selector"],
mesh_config["num_replicas"],
mesh_config["pod_rank_label"],
timeout=5,
)
return True
except RuntimeError:
return False
def _kill(self) -> None:
"""
Delete MonarchMesh CRDs for provisioned meshes.
Raises:
NotImplementedError: If no provisioned meshes exist (all
meshes are attach-only).
"""
self._terminate_port_forwards()
provisioned = [
name for name, cfg in self._meshes.items() if cfg.get("provisioned")
]
if not provisioned:
raise NotImplementedError(
"KubernetesJob currently does not support killing pods."
)
self._kubeconfig.load()
api = client.CustomObjectsApi()
for mesh_name in provisioned:
try:
api.delete_namespaced_custom_object(
group=_MONARCHMESH_GROUP,
version=_MONARCHMESH_VERSION,
namespace=self._namespace,
plural=_MONARCHMESH_PLURAL,
name=mesh_name,
)
logger.info("Deleted MonarchMesh '%s'", mesh_name)
except ApiException as e:
if e.status == 404:
logger.info("MonarchMesh '%s' already deleted", mesh_name)
else:
raise