Skip to content

Commit 8ecfff2

Browse files
lukebaumanncopybara-github
authored andcommitted
Add YAML serialization and deployment to GKE for PathwaysJobSet.
PiperOrigin-RevId: 949601795
1 parent cf82422 commit 8ecfff2

3 files changed

Lines changed: 720 additions & 15 deletions

File tree

pathwaysutils/experimental/gke/jobset.py

Lines changed: 190 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
import json
1616
import logging
1717
import math
18+
import time
1819
from typing import Any, Mapping, Sequence
1920
from kubernetes import client
21+
from kubernetes import config as k8s_config
22+
import yaml
2023

2124
# GKE sidecar containers restartPolicy compatibility placeholder.
2225

@@ -179,6 +182,14 @@ def __init__(
179182
"targetReplicatedJobs": [PATHWAYS_HEAD_JOB_NAME],
180183
}
181184

185+
@property
186+
def head_job_template(self) -> client.V1JobTemplateSpec:
187+
return self._head_job_template
188+
189+
@property
190+
def worker_job_template(self) -> client.V1JobTemplateSpec:
191+
return self._worker_job_template
192+
182193
def _build_head_job_template(
183194
self,
184195
pathways_dir: str,
@@ -696,29 +707,43 @@ def _compile_config(self) -> dict[str, Any]:
696707
self._worker_job_template
697708
)
698709

699-
replicated_jobs = [
700-
{
701-
"name": PATHWAYS_HEAD_JOB_NAME,
702-
"replicas": 1,
703-
"template": serialized_head,
704-
},
705-
{
706-
"name": PATHWAYS_WORKER_JOB_NAME,
707-
"replicas": self._worker_replicas,
708-
"template": serialized_worker,
709-
},
710-
]
710+
head_job = {
711+
"name": PATHWAYS_HEAD_JOB_NAME,
712+
"replicas": 1,
713+
"template": serialized_head,
714+
}
715+
worker_job = {
716+
"name": PATHWAYS_WORKER_JOB_NAME,
717+
"replicas": self._worker_replicas,
718+
"template": serialized_worker,
719+
}
720+
721+
coordinator = {
722+
"replicatedJob": PATHWAYS_HEAD_JOB_NAME,
723+
}
724+
725+
failure_policy: dict[str, Any] = {
726+
"restartStrategy": "Recreate",
727+
}
728+
if self._max_restarts > 0:
729+
failure_policy["maxRestarts"] = self._max_restarts
711730

712731
jobset_config = {
713-
"apiVersion": f"jobset.sigs.k8s.io/{self._jobset_api_version}",
732+
"apiVersion": f"jobset.x-k8s.io/{self._jobset_api_version}",
714733
"kind": "JobSet",
715734
"metadata": {
716735
"name": self._name,
717736
"namespace": self._namespace,
718737
},
719738
"spec": {
720-
"failurePolicy": {"maxRestarts": self._max_restarts},
721-
"replicatedJobs": replicated_jobs,
739+
"startupPolicy": {"startupPolicyOrder": "InOrder"},
740+
"failurePolicy": failure_policy,
741+
"network": {
742+
"enableDNSHostnames": True,
743+
"publishNotReadyAddresses": True,
744+
},
745+
"coordinator": coordinator,
746+
"replicatedJobs": [head_job, worker_job],
722747
},
723748
}
724749
if self._labels:
@@ -733,3 +758,153 @@ def _compile_config(self) -> dict[str, Any]:
733758
def to_dict(self) -> dict[str, Any]:
734759
"""Returns the JobSet configuration as a dictionary."""
735760
return self._compile_config()
761+
762+
def export_yaml(self, filepath: str) -> None:
763+
"""Exports the JobSet configuration to a YAML file."""
764+
with open(filepath, "w") as f:
765+
yaml.dump(self.to_dict(), f, default_flow_style=False)
766+
767+
@classmethod
768+
def import_yaml(cls, filepath: str) -> "PathwaysJobSet":
769+
"""Imports a JobSet configuration from a YAML file."""
770+
with open(filepath, "r") as f:
771+
config = yaml.safe_load(f)
772+
773+
cls._validate_config(config)
774+
775+
instance = cls.__new__(cls)
776+
instance._name = config["metadata"]["name"]
777+
instance._namespace = config["metadata"].get("namespace", "default")
778+
api_version_parts = config.get("apiVersion", "").split("/")
779+
instance._jobset_api_version = (
780+
api_version_parts[-1] if len(api_version_parts) > 1 else "v1alpha2"
781+
)
782+
instance._max_restarts = (
783+
config["spec"].get("failurePolicy", {}).get("maxRestarts", 0)
784+
)
785+
instance._labels = config["metadata"].get("labels", {})
786+
instance._annotations = config["metadata"].get("annotations", {})
787+
788+
# Extract replicated jobs and deserialize.
789+
head_job_template: client.V1JobTemplateSpec | None = None
790+
worker_job_template: client.V1JobTemplateSpec | None = None
791+
792+
with client.ApiClient() as api_client:
793+
for job in config["spec"]["replicatedJobs"]:
794+
if job["name"] == PATHWAYS_HEAD_JOB_NAME:
795+
head_job_template = _deserialize_dict(
796+
api_client, job["template"], client.V1JobTemplateSpec
797+
)
798+
elif job["name"] in ("worker", PATHWAYS_WORKER_JOB_NAME):
799+
worker_job_template = _deserialize_dict(
800+
api_client, job["template"], client.V1JobTemplateSpec
801+
)
802+
instance._worker_replicas = job["replicas"]
803+
804+
if head_job_template is None:
805+
raise ValueError(f"Missing head job ({PATHWAYS_HEAD_JOB_NAME}) in config")
806+
if worker_job_template is None:
807+
raise ValueError(
808+
f"Missing worker job ({PATHWAYS_WORKER_JOB_NAME}) in config"
809+
)
810+
811+
instance._head_job_template = head_job_template
812+
instance._worker_job_template = worker_job_template
813+
814+
instance._success_policy = config["spec"].get("successPolicy")
815+
return instance
816+
817+
@classmethod
818+
def _validate_config(cls, config: dict[str, Any]) -> None:
819+
"""Validates that the config is a valid Pathways JobSet."""
820+
if config.get("kind") != "JobSet":
821+
raise ValueError("Resource kind is not JobSet")
822+
jobs = {
823+
j["name"]: j for j in config.get("spec", {}).get("replicatedJobs", [])
824+
}
825+
if "head" not in jobs and PATHWAYS_HEAD_JOB_NAME not in jobs:
826+
raise ValueError(
827+
f"Missing head replicated job ('head' or '{PATHWAYS_HEAD_JOB_NAME}')"
828+
)
829+
if "worker" not in jobs and PATHWAYS_WORKER_JOB_NAME not in jobs:
830+
raise ValueError(
831+
"Missing worker replicated job ('worker' or"
832+
f" '{PATHWAYS_WORKER_JOB_NAME}')"
833+
)
834+
835+
def apply(
836+
self, recreate: bool = False, field_manager: str = "pathwaysutils"
837+
) -> None:
838+
"""Applies the JobSet to the GKE cluster."""
839+
840+
try:
841+
k8s_config.load_kube_config()
842+
except Exception: # pylint: disable=broad-except
843+
try:
844+
k8s_config.load_incluster_config()
845+
except Exception as e:
846+
raise RuntimeError("Failed to load Kubernetes configuration") from e
847+
848+
api = client.CustomObjectsApi()
849+
group = "jobset.x-k8s.io"
850+
version = self._jobset_api_version
851+
plural = "jobsets"
852+
853+
exists = False
854+
try:
855+
api.get_namespaced_custom_object(
856+
group, version, self._namespace, plural, self._name
857+
)
858+
exists = True
859+
except client.rest.ApiException as e:
860+
if e.status != 404:
861+
raise
862+
863+
if exists:
864+
if recreate:
865+
_logger.info(
866+
"JobSet %s already exists. Deleting it first...", self._name
867+
)
868+
api.delete_namespaced_custom_object(
869+
group, version, self._namespace, plural, self._name
870+
)
871+
872+
# Poll for deletion.
873+
max_retries = 30
874+
for i in range(max_retries):
875+
try:
876+
api.get_namespaced_custom_object(
877+
group, version, self._namespace, plural, self._name
878+
)
879+
_logger.info(
880+
"Waiting for JobSet %s to be deleted... (%d/%d)",
881+
self._name,
882+
i + 1,
883+
max_retries,
884+
)
885+
time.sleep(2)
886+
except client.rest.ApiException as e:
887+
if e.status == 404:
888+
_logger.info("JobSet %s deleted.", self._name)
889+
break
890+
raise
891+
else:
892+
raise RuntimeError(
893+
f"Timeout waiting for JobSet {self._name} to be deleted"
894+
)
895+
else:
896+
raise RuntimeError(
897+
f"JobSet {self._name} already exists. Use recreate=True to"
898+
" overwrite."
899+
)
900+
901+
_logger.info("Creating JobSet %s...", self._name)
902+
api.create_namespaced_custom_object(
903+
group=group,
904+
version=version,
905+
namespace=self._namespace,
906+
plural=plural,
907+
body=self.to_dict(),
908+
field_manager=field_manager,
909+
)
910+
_logger.info("JobSet %s created successfully.", self._name)

0 commit comments

Comments
 (0)